rails_pulse 0.1.1 → 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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +79 -177
  3. data/Rakefile +77 -2
  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 -17
  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/chart_table_concern.rb +21 -4
  14. data/app/controllers/concerns/response_range_concern.rb +6 -3
  15. data/app/controllers/concerns/time_range_concern.rb +5 -10
  16. data/app/controllers/concerns/zoom_range_concern.rb +32 -1
  17. data/app/controllers/rails_pulse/application_controller.rb +13 -5
  18. data/app/controllers/rails_pulse/dashboard_controller.rb +12 -0
  19. data/app/controllers/rails_pulse/queries_controller.rb +111 -51
  20. data/app/controllers/rails_pulse/requests_controller.rb +37 -12
  21. data/app/controllers/rails_pulse/routes_controller.rb +98 -24
  22. data/app/helpers/rails_pulse/application_helper.rb +0 -1
  23. data/app/helpers/rails_pulse/chart_formatters.rb +3 -3
  24. data/app/helpers/rails_pulse/chart_helper.rb +21 -9
  25. data/app/helpers/rails_pulse/status_helper.rb +10 -4
  26. data/app/javascript/rails_pulse/application.js +34 -3
  27. data/app/javascript/rails_pulse/controllers/collapsible_controller.js +32 -0
  28. data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +2 -1
  29. data/app/javascript/rails_pulse/controllers/expandable_rows_controller.js +58 -0
  30. data/app/javascript/rails_pulse/controllers/index_controller.js +353 -39
  31. data/app/javascript/rails_pulse/controllers/pagination_controller.js +17 -27
  32. data/app/javascript/rails_pulse/controllers/popover_controller.js +28 -4
  33. data/app/javascript/rails_pulse/controllers/table_sort_controller.js +14 -0
  34. data/app/jobs/rails_pulse/backfill_summaries_job.rb +41 -0
  35. data/app/jobs/rails_pulse/summary_job.rb +53 -0
  36. data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +28 -7
  37. data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +18 -22
  38. data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +18 -7
  39. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +34 -41
  40. data/app/models/rails_pulse/operation.rb +1 -1
  41. data/app/models/rails_pulse/queries/cards/average_query_times.rb +49 -25
  42. data/app/models/rails_pulse/queries/cards/execution_rate.rb +40 -28
  43. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +37 -43
  44. data/app/models/rails_pulse/queries/charts/average_query_times.rb +23 -97
  45. data/app/models/rails_pulse/queries/tables/index.rb +74 -0
  46. data/app/models/rails_pulse/query.rb +47 -0
  47. data/app/models/rails_pulse/requests/charts/average_response_times.rb +23 -84
  48. data/app/models/rails_pulse/route.rb +1 -6
  49. data/app/models/rails_pulse/routes/cards/average_response_times.rb +45 -25
  50. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +43 -45
  51. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +36 -44
  52. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +37 -27
  53. data/app/models/rails_pulse/routes/charts/average_response_times.rb +23 -100
  54. data/app/models/rails_pulse/routes/tables/index.rb +57 -40
  55. data/app/models/rails_pulse/summary.rb +143 -0
  56. data/app/services/rails_pulse/analysis/backtrace_analyzer.rb +256 -0
  57. data/app/services/rails_pulse/analysis/base_analyzer.rb +67 -0
  58. data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +206 -0
  59. data/app/services/rails_pulse/analysis/index_recommendation_engine.rb +326 -0
  60. data/app/services/rails_pulse/analysis/n_plus_one_detector.rb +241 -0
  61. data/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb +146 -0
  62. data/app/services/rails_pulse/analysis/suggestion_generator.rb +217 -0
  63. data/app/services/rails_pulse/query_analysis_service.rb +125 -0
  64. data/app/services/rails_pulse/summary_service.rb +199 -0
  65. data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +0 -1
  66. data/app/views/layouts/rails_pulse/application.html.erb +4 -6
  67. data/app/views/rails_pulse/components/_breadcrumbs.html.erb +1 -1
  68. data/app/views/rails_pulse/components/_code_panel.html.erb +17 -3
  69. data/app/views/rails_pulse/components/_empty_state.html.erb +11 -0
  70. data/app/views/rails_pulse/components/_metric_card.html.erb +37 -28
  71. data/app/views/rails_pulse/components/_panel.html.erb +1 -1
  72. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +5 -7
  73. data/app/views/rails_pulse/components/_table_head.html.erb +6 -1
  74. data/app/views/rails_pulse/dashboard/index.html.erb +55 -37
  75. data/app/views/rails_pulse/operations/show.html.erb +17 -15
  76. data/app/views/rails_pulse/queries/_analysis_error.html.erb +15 -0
  77. data/app/views/rails_pulse/queries/_analysis_prompt.html.erb +27 -0
  78. data/app/views/rails_pulse/queries/_analysis_results.html.erb +87 -0
  79. data/app/views/rails_pulse/queries/_analysis_section.html.erb +39 -0
  80. data/app/views/rails_pulse/queries/_show_table.html.erb +2 -2
  81. data/app/views/rails_pulse/queries/_table.html.erb +11 -13
  82. data/app/views/rails_pulse/queries/index.html.erb +32 -28
  83. data/app/views/rails_pulse/queries/show.html.erb +45 -34
  84. data/app/views/rails_pulse/requests/_operations.html.erb +38 -45
  85. data/app/views/rails_pulse/requests/_table.html.erb +3 -3
  86. data/app/views/rails_pulse/requests/index.html.erb +33 -28
  87. data/app/views/rails_pulse/routes/_table.html.erb +14 -14
  88. data/app/views/rails_pulse/routes/index.html.erb +34 -29
  89. data/app/views/rails_pulse/routes/show.html.erb +43 -36
  90. data/config/initializers/rails_pulse.rb +0 -12
  91. data/config/routes.rb +5 -1
  92. data/db/migrate/20241222000001_create_rails_pulse_summaries.rb +54 -0
  93. data/db/migrate/20250916031656_add_analysis_to_rails_pulse_queries.rb +13 -0
  94. data/db/rails_pulse_schema.rb +130 -0
  95. data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +65 -0
  96. data/lib/generators/rails_pulse/install_generator.rb +94 -4
  97. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +60 -0
  98. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +22 -0
  99. data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +19 -0
  100. data/lib/generators/rails_pulse/templates/rails_pulse.rb +0 -12
  101. data/lib/generators/rails_pulse/upgrade_generator.rb +225 -0
  102. data/lib/rails_pulse/configuration.rb +0 -11
  103. data/lib/rails_pulse/engine.rb +0 -1
  104. data/lib/rails_pulse/version.rb +1 -1
  105. data/lib/tasks/rails_pulse.rake +77 -0
  106. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  107. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  108. data/public/rails-pulse-assets/rails-pulse.js +53 -53
  109. data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
  110. data/public/rails-pulse-assets/search.svg +43 -0
  111. metadata +48 -14
  112. data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
  113. data/app/assets/images/rails_pulse/routes.png +0 -0
  114. data/app/controllers/rails_pulse/caches_controller.rb +0 -115
  115. data/app/helpers/rails_pulse/cached_component_helper.rb +0 -73
  116. data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +0 -67
  117. data/app/models/rails_pulse/component_cache_key.rb +0 -33
  118. data/app/views/rails_pulse/caches/show.html.erb +0 -9
  119. data/db/migrate/20250227235904_create_routes.rb +0 -12
  120. data/db/migrate/20250227235915_create_requests.rb +0 -19
  121. data/db/migrate/20250228000000_create_queries.rb +0 -14
  122. data/db/migrate/20250228000056_create_operations.rb +0 -24
  123. data/lib/rails_pulse/migration.rb +0 -29
@@ -56,3 +56,16 @@
56
56
  --badge-border-color: transparent;
57
57
  --badge-color: var(--color-negative);
58
58
  }
59
+
60
+ /* Trend badge icon lightening (dark mode only) */
61
+ .badge--trend rails-pulse-icon { color: var(--badge-color, currentColor); }
62
+ html[data-color-scheme="dark"] .badge--trend rails-pulse-icon {
63
+ /* Lighten icon relative to badge text color for contrast */
64
+ color: color-mix(in srgb, var(--badge-color) 55%, white 45%);
65
+ }
66
+
67
+ /* Trend amount lightening (dark mode only) */
68
+ .badge--trend .badge__trend-amount { color: var(--badge-color, currentColor); }
69
+ html[data-color-scheme="dark"] .badge--trend .badge__trend-amount {
70
+ color: color-mix(in srgb, var(--badge-color) 55%, white 45%);
71
+ }
@@ -5,6 +5,10 @@
5
5
  --color-text-reversed: white;
6
6
  --color-text-subtle: var(--zinc-500);
7
7
  --color-link: var(--blue-700);
8
+ /* Header tokens */
9
+ --header-bg: #ffc91f;
10
+ --header-link: black;
11
+ --header-link-hover-bg: #ffe284;
8
12
  --color-border-light: var(--zinc-100);
9
13
  --color-border: var(--zinc-200);
10
14
  --color-border-dark: var(--zinc-400);
@@ -30,8 +34,9 @@ html[data-color-scheme="dark"] {
30
34
  --color-bg: var(--zinc-800);
31
35
  --color-text: white;
32
36
  --color-text-reversed: black;
33
- --color-text-subtle: var(--zinc-400);
34
- --color-link: var(--blue-400);
37
+ --color-text-subtle: var(--zinc-300);
38
+ /* Use brand yellow for links in dark mode */
39
+ --color-link: #ffc91f;
35
40
  --color-border-light: var(--zinc-900);
36
41
  --color-border: var(--zinc-800);
37
42
  --color-border-dark: var(--zinc-600);
@@ -39,6 +44,11 @@ html[data-color-scheme="dark"] {
39
44
  --color-selected-dark: var(--blue-800);
40
45
  --color-highlight: var(--yellow-900);
41
46
 
47
+ /* Header tokens */
48
+ --header-bg: rgb(32, 32, 32);
49
+ --header-link: #ffc91f;
50
+ --header-link-hover-bg: #ffe284; /* keep existing hover color */
51
+
42
52
  /* Accent colors */
43
53
  --color-primary: var(--zinc-50);
44
54
  --color-secondary: var(--zinc-800);
@@ -0,0 +1,30 @@
1
+ .collapsible-code.collapsed pre {
2
+ max-height: 4.5em;
3
+ overflow: hidden;
4
+ position: relative;
5
+ }
6
+
7
+ .collapsible-code.collapsed pre::after {
8
+ content: '';
9
+ position: absolute;
10
+ bottom: 0;
11
+ left: 0;
12
+ right: 0;
13
+ height: 1em;
14
+ background: linear-gradient(transparent, var(--color-border-light));
15
+ pointer-events: none;
16
+ }
17
+
18
+ .collapsible-toggle {
19
+ margin-top: 0.5rem;
20
+ font-size: 0.875rem;
21
+ color: var(--color-link);
22
+ text-decoration: underline;
23
+ transform: lowercase;
24
+ cursor: pointer;
25
+ border: none;
26
+ background: none;
27
+ padding: 0;
28
+ font-weight: normal;
29
+ margin-left: 10px;
30
+ }
@@ -31,6 +31,5 @@
31
31
  left: var(--popover-x, 0) !important;
32
32
  top: var(--popover-y, 0) !important;
33
33
  margin: 0 !important;
34
- inset: unset !important;
35
34
  }
36
35
  }
@@ -3,22 +3,74 @@
3
3
  justify-content: space-between;
4
4
  width: 100%;
5
5
  gap: var(--column-gap, 0.5rem);
6
+ align-items: stretch;
6
7
  }
7
8
 
8
9
  .row > * {
9
10
  flex: 1;
10
11
  min-width: 0;
12
+ display: flex;
13
+ flex-direction: column;
11
14
  }
12
15
 
13
- /* Stack items on smaller screens */
16
+ /* Ensure metric cards and their panels stretch to full height */
17
+ .row > .grid-item {
18
+ display: flex;
19
+ flex-direction: column;
20
+ }
21
+
22
+ .row > .grid-item > * {
23
+ flex: 1;
24
+ }
25
+
26
+ /* Responsive layout for screens smaller than 768px */
14
27
  @media (max-width: 768px) {
15
28
  .row {
16
- flex-direction: column;
29
+ display: flex;
30
+ flex-wrap: wrap;
31
+ justify-content: space-between;
17
32
  gap: 0.5rem;
33
+ align-items: flex-start;
18
34
  }
19
35
 
20
36
  .row > * {
37
+ flex: 0 0 calc(50% - 0.25rem);
38
+ min-width: 0;
39
+ height: auto;
40
+ }
41
+
42
+ .row > .grid-item {
43
+ height: auto;
44
+ }
45
+
46
+ .row > .grid-item > * {
21
47
  flex: none;
22
- width: 100%;
48
+ }
49
+
50
+ /* Tables should stack in single column on mobile */
51
+ .row:has(.table-container) > * {
52
+ flex: 0 0 100%;
53
+ }
54
+
55
+ /* Single column for very small screens */
56
+ @media (max-width: 480px) {
57
+ .row > * {
58
+ flex: 0 0 100%;
59
+ }
60
+
61
+ .row > .grid-item {
62
+ min-height: auto;
63
+ }
64
+
65
+ /* Make metric cards more compact on mobile */
66
+ .row > .grid-item .card {
67
+ padding: var(--size-3);
68
+ }
69
+
70
+ /* Make charts smaller on mobile */
71
+ .row > .grid-item .chart-container {
72
+ height: 60px !important;
73
+ max-height: 60px;
74
+ }
23
75
  }
24
76
  }
@@ -77,3 +77,26 @@
77
77
  padding: var(--size-0_5) var(--size-2);
78
78
  row-gap: var(--size-1);
79
79
  }
80
+
81
+ /* Sheet component styles for mobile menu */
82
+ .sheet {
83
+ border: 0;
84
+ background: var(--color-bg);
85
+ max-block-size: none;
86
+ max-inline-size: none;
87
+ padding: 0;
88
+ }
89
+
90
+ .sheet--left {
91
+ block-size: 100vh;
92
+ inline-size: var(--sheet-size, 288px);
93
+ inset-block-start: 0;
94
+ inset-inline-start: 0;
95
+ }
96
+
97
+ .sheet__content {
98
+ block-size: 100%;
99
+ display: flex;
100
+ flex-direction: column;
101
+ overflow-y: auto;
102
+ }
@@ -16,14 +16,17 @@ module ChartTableConcern
16
16
  def setup_chart_and_table_data
17
17
  ransack_params = params[:q] || {}
18
18
 
19
- # Setup chart data first using original time range (no sorting from table)
20
19
  unless turbo_frame_request?
21
- setup_chart_formatters
20
+ # Setup chart data first using original time range (no sorting from table)
22
21
  setup_chart_data(ransack_params)
22
+ setup_chart_formatters
23
23
  end
24
24
 
25
25
  # Setup table data using zoom parameters if present, otherwise use chart parameters
26
26
  setup_table_data(ransack_params)
27
+
28
+ # Set flag to determine if we have meaningful data to display
29
+ @has_data = has_meaningful_data?
27
30
  end
28
31
 
29
32
  def setup_chart_data(ransack_params)
@@ -31,7 +34,10 @@ module ChartTableConcern
31
34
  chart_ransack_query = chart_model.ransack(chart_ransack_params)
32
35
  @chart_data = chart_class.new(
33
36
  ransack_query: chart_ransack_query,
34
- group_by: group_by,
37
+ period_type: period_type,
38
+ start_time: @start_time,
39
+ end_time: @end_time,
40
+ start_duration: @start_duration,
35
41
  **chart_options
36
42
  ).to_rails_chart
37
43
  end
@@ -43,6 +49,7 @@ module ChartTableConcern
43
49
 
44
50
  table_results = build_table_results
45
51
  handle_pagination
52
+
46
53
  @pagy, @table_data = pagy(table_results, limit: session_pagination_limit)
47
54
  end
48
55
 
@@ -56,14 +63,24 @@ module ChartTableConcern
56
63
  end
57
64
 
58
65
  def setup_chart_formatters
59
- @xaxis_formatter = RailsPulse::ChartFormatters.occurred_at_as_time_or_date(@time_diff_hours)
66
+ @xaxis_formatter = RailsPulse::ChartFormatters.period_as_time_or_date(@time_diff_hours)
60
67
  @tooltip_formatter = RailsPulse::ChartFormatters.tooltip_as_time_or_date_with_marker(@time_diff_hours)
61
68
  end
62
69
 
70
+ def period_type
71
+ @time_diff_hours <= 25 ? :hour : :day
72
+ end
73
+
63
74
  def group_by
64
75
  @time_diff_hours <= 25 ? :group_by_hour : :group_by_day
65
76
  end
66
77
 
78
+ def has_meaningful_data?
79
+ has_chart_data = @chart_data && @chart_data.values.any? { |v| v > 0 }
80
+ has_table_data = @table_data && @table_data.any?
81
+ has_chart_data || has_table_data
82
+ end
83
+
67
84
  def handle_pagination
68
85
  method = pagination_method
69
86
  send(method, params[:limit]) if params[:limit].present?
@@ -5,10 +5,13 @@ module ResponseRangeConcern
5
5
  ransack_params = params[:q] || {}
6
6
  thresholds = RailsPulse.configuration.public_send("#{type}_thresholds")
7
7
 
8
- if ransack_params[:duration].present?
9
- selected_range = ransack_params[:duration]
8
+ # Check both avg_duration (for Summary) and duration (for Request/Operation)
9
+ duration_param = ransack_params[:avg_duration] || ransack_params[:duration]
10
+
11
+ if duration_param.present?
12
+ selected_range = duration_param
10
13
  start_duration =
11
- case ransack_params[:duration].to_sym
14
+ case duration_param.to_sym
12
15
  when :slow then thresholds[:slow]
13
16
  when :very_slow then thresholds[:very_slow]
14
17
  when :critical then thresholds[:critical]
@@ -6,8 +6,7 @@ module TimeRangeConcern
6
6
  const_set(:TIME_RANGE_OPTIONS, [
7
7
  [ "Last 24 hours", :last_day ],
8
8
  [ "Last Week", :last_week ],
9
- [ "Last Month", :last_month ],
10
- [ "All Time", :all_time ]
9
+ [ "Last Month", :last_month ]
11
10
  ].freeze)
12
11
  end
13
12
 
@@ -18,23 +17,19 @@ module TimeRangeConcern
18
17
 
19
18
  ransack_params = params[:q] || {}
20
19
 
21
- if ransack_params[:requests_occurred_at_gteq].present?
22
- # Custom time range from routes index chart zoom which filters requests through an association
23
- start_time = parse_time_param(ransack_params[:requests_occurred_at_gteq])
24
- end_time = parse_time_param(ransack_params[:requests_occurred_at_lt])
25
- elsif ransack_params[:occurred_at_gteq].present?
20
+ if ransack_params[:occurred_at_gteq].present?
26
21
  # Custom time range from chart zoom where there is no association
27
22
  start_time = parse_time_param(ransack_params[:occurred_at_gteq])
28
23
  end_time = parse_time_param(ransack_params[:occurred_at_lt])
29
- elsif ransack_params[:occurred_at_range]
24
+ elsif ransack_params[:period_start_range]
30
25
  # Predefined time range from dropdown
31
- selected_time_range = ransack_params[:occurred_at_range]
26
+ selected_time_range = ransack_params[:period_start_range]
32
27
  start_time =
33
28
  case selected_time_range.to_sym
34
29
  when :last_day then 1.day.ago
35
30
  when :last_week then 1.week.ago
36
31
  when :last_month then 1.month.ago
37
- when :all_time then 100.years.ago
32
+ else 1.day.ago # Default fallback
38
33
  end
39
34
  end
40
35
 
@@ -2,10 +2,22 @@ module ZoomRangeConcern
2
2
  extend ActiveSupport::Concern
3
3
 
4
4
  def setup_zoom_range(main_start_time, main_end_time)
5
+ # Extract column selection parameter (but don't delete it yet - we need it for the view)
6
+ selected_column_time = params[:selected_column_time]
7
+
5
8
  # Extract zoom parameters from params (this removes them from params)
6
9
  zoom_start = params.delete(:zoom_start_time)
7
10
  zoom_end = params.delete(:zoom_end_time)
8
11
 
12
+ # Handle column selection with highest precedence for table filtering
13
+ if selected_column_time
14
+ column_start, column_end = normalize_column_time(selected_column_time.to_i, main_start_time, main_end_time)
15
+ table_start_time = column_start
16
+ table_end_time = column_end
17
+ # Don't set zoom times for column selection - let chart show full range
18
+ return [ zoom_start, zoom_end, table_start_time, table_end_time ]
19
+ end
20
+
9
21
  # Normalize zoom times to beginning/end of day or hour like we do for main time range
10
22
  if zoom_start && zoom_end
11
23
  zoom_start, zoom_end = normalize_zoom_times(zoom_start.to_i, zoom_end.to_i)
@@ -20,6 +32,25 @@ module ZoomRangeConcern
20
32
 
21
33
  private
22
34
 
35
+ def normalize_column_time(column_time, main_start_time, main_end_time)
36
+ # Determine period type based on main time range (same logic as ChartTableConcern)
37
+ time_diff_hours = (main_end_time - main_start_time) / 3600.0
38
+
39
+ if time_diff_hours <= 25
40
+ # Hourly period - normalize to beginning/end of hour
41
+ column_time_obj = Time.zone&.at(column_time) || Time.at(column_time)
42
+ start_time = column_time_obj&.beginning_of_hour || column_time_obj
43
+ end_time = column_time_obj&.end_of_hour || column_time_obj
44
+ else
45
+ # Daily period - normalize to beginning/end of day
46
+ column_time_obj = Time.zone&.at(column_time) || Time.at(column_time)
47
+ start_time = column_time_obj&.beginning_of_day || column_time_obj
48
+ end_time = column_time_obj&.end_of_day || column_time_obj
49
+ end
50
+
51
+ [ start_time.to_i, end_time.to_i ]
52
+ end
53
+
23
54
  def normalize_zoom_times(start_time, end_time)
24
55
  time_diff = (end_time - start_time) / 3600.0
25
56
 
@@ -35,6 +66,6 @@ module ZoomRangeConcern
35
66
  end_time = end_time_obj&.end_of_day || end_time_obj
36
67
  end
37
68
 
38
- [ start_time, end_time ]
69
+ [ start_time.to_i, end_time.to_i ]
39
70
  end
40
71
  end
@@ -2,9 +2,14 @@ module RailsPulse
2
2
  class ApplicationController < ActionController::Base
3
3
  before_action :authenticate_rails_pulse_user!
4
4
 
5
- def set_pagination_limit
6
- session[:pagination_limit] = params[:limit].to_i if params[:limit].present?
7
- render json: { status: "ok" }
5
+ def set_pagination_limit(limit = nil)
6
+ limit = limit || params[:limit]
7
+ session[:pagination_limit] = limit.to_i if limit.present?
8
+
9
+ # Render JSON for direct API calls or AJAX requests (but not turbo frame requests)
10
+ if (request.xhr? && !turbo_frame_request?) || (request.patch? && action_name == "set_pagination_limit")
11
+ render json: { status: "ok" }
12
+ end
8
13
  end
9
14
 
10
15
  private
@@ -54,8 +59,11 @@ module RailsPulse
54
59
  end
55
60
 
56
61
  def session_pagination_limit
57
- # Keep default small for optimal performance
58
- session[:pagination_limit] || 10
62
+ # Use URL param if present, otherwise session, otherwise default
63
+ limit = params[:limit].presence || session[:pagination_limit] || 10
64
+ # Update session if URL param was used
65
+ session[:pagination_limit] = limit.to_i if params[:limit].present?
66
+ limit.to_i
59
67
  end
60
68
 
61
69
  def store_pagination_limit(limit)
@@ -1,6 +1,18 @@
1
1
  module RailsPulse
2
2
  class DashboardController < ApplicationController
3
3
  def index
4
+ @average_query_times_metric_card = RailsPulse::Routes::Cards::AverageResponseTimes.new(route: nil).to_metric_card
5
+ @percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(route: nil).to_metric_card
6
+ @request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(route: nil).to_metric_card
7
+ @error_rate_per_route_metric_card = RailsPulse::Routes::Cards::ErrorRatePerRoute.new(route: nil).to_metric_card
8
+
9
+ # Generate chart data for inline rendering
10
+ @average_response_time_chart_data = RailsPulse::Dashboard::Charts::AverageResponseTime.new.to_chart_data
11
+ @p95_response_time_chart_data = RailsPulse::Dashboard::Charts::P95ResponseTime.new.to_chart_data
12
+
13
+ # Generate table data for inline rendering
14
+ @slow_routes_table_data = RailsPulse::Dashboard::Tables::SlowRoutes.new.to_table_data
15
+ @slow_queries_table_data = RailsPulse::Dashboard::Tables::SlowQueries.new.to_table_data
4
16
  end
5
17
  end
6
18
  end
@@ -2,24 +2,60 @@ module RailsPulse
2
2
  class QueriesController < ApplicationController
3
3
  include ChartTableConcern
4
4
 
5
- before_action :set_query, only: :show
5
+ before_action :set_query, only: [ :show, :analyze ]
6
6
 
7
7
  def index
8
+ setup_metric_cards
8
9
  setup_chart_and_table_data
9
10
  end
10
11
 
11
12
  def show
13
+ setup_metric_cards
12
14
  setup_chart_and_table_data
13
15
  end
14
16
 
17
+ def analyze
18
+ begin
19
+ @analysis_results = QueryAnalysisService.analyze_query(@query.id)
20
+
21
+ respond_to do |format|
22
+ format.turbo_stream {
23
+ render turbo_stream: turbo_stream.replace(
24
+ "query_analysis",
25
+ partial: "rails_pulse/queries/analysis_section",
26
+ locals: { query: @query.reload }
27
+ )
28
+ }
29
+ format.html {
30
+ redirect_to query_path(@query), notice: "Query analysis completed successfully."
31
+ }
32
+ end
33
+ rescue => e
34
+ Rails.logger.error("[QueryAnalysis] Analysis failed for query #{@query.id}: #{e.message}")
35
+
36
+ respond_to do |format|
37
+ format.turbo_stream {
38
+ render turbo_stream: turbo_stream.replace(
39
+ "query_analysis",
40
+ partial: "rails_pulse/queries/analysis_section",
41
+ locals: { query: @query, error_message: "Analysis failed: #{e.message}" }
42
+ )
43
+ }
44
+ format.html {
45
+ redirect_to query_path(@query), alert: "Query analysis failed: #{e.message}"
46
+ }
47
+ end
48
+ end
49
+ end
50
+
15
51
  private
16
52
 
17
53
  def chart_model
18
- show_action? ? Operation : Query
54
+ Summary
19
55
  end
20
56
 
21
57
  def table_model
22
- show_action? ? Operation : Query
58
+ show_action? ? Operation : Summary
23
59
  end
24
60
 
25
61
  def chart_class
@@ -31,73 +67,78 @@ module RailsPulse
31
67
  end
32
68
 
33
69
  def build_chart_ransack_params(ransack_params)
34
- base_params = ransack_params.except(:s)
70
+ base_params = ransack_params.except(:s).merge(
71
+ period_start_gteq: Time.at(@start_time),
72
+ period_start_lt: Time.at(@end_time)
73
+ )
74
+
75
+ # Only add duration filter if we have a meaningful threshold
76
+ base_params[:avg_duration_gteq] = @start_duration if @start_duration && @start_duration > 0
35
77
 
36
78
  if show_action?
37
- base_params.merge(
38
- query_id_eq: @query.id,
39
- occurred_at_gteq: @start_time,
40
- occurred_at_lt: @end_time,
41
- duration_gteq: @start_duration
42
- )
79
+ base_params.merge(summarizable_id_eq: @query.id)
43
80
  else
44
- base_params.merge(
45
- operations_occurred_at_gteq: @start_time,
46
- operations_occurred_at_lt: @end_time,
47
- operations_duration_gteq: @start_duration
48
- )
81
+ base_params
49
82
  end
50
83
  end
51
84
 
52
85
  def build_table_ransack_params(ransack_params)
53
86
  if show_action?
54
- ransack_params.merge(
55
- query_id_eq: @query.id,
56
- occurred_at_gteq: @table_start_time,
57
- occurred_at_lt: @table_end_time,
58
- duration_gteq: @start_duration
87
+ # For Operation model on show page
88
+ params = ransack_params.merge(
89
+ occurred_at_gteq: Time.at(@table_start_time),
90
+ occurred_at_lt: Time.at(@table_end_time),
91
+ query_id_eq: @query.id
59
92
  )
93
+ params[:duration_gteq] = @start_duration if @start_duration && @start_duration > 0
94
+ params
60
95
  else
61
- ransack_params.merge(
62
- operations_occurred_at_gteq: @table_start_time,
63
- operations_occurred_at_lt: @table_end_time,
64
- operations_duration_gteq: @start_duration
96
+ # For Summary model on index page
97
+ params = ransack_params.merge(
98
+ period_start_gteq: Time.at(@table_start_time),
99
+ period_start_lt: Time.at(@table_end_time)
65
100
  )
101
+ params[:avg_duration_gteq] = @start_duration if @start_duration && @start_duration > 0
102
+ params
66
103
  end
67
104
  end
68
105
 
69
106
  def default_table_sort
70
- "occurred_at desc"
107
+ show_action? ? "occurred_at desc" : "period_start desc"
71
108
  end
72
109
 
73
110
  def build_table_results
74
111
  if show_action?
75
- @ransack_query.result.select("id", "occurred_at", "duration")
112
+ # Only show operations that belong to time periods where we have query summaries
113
+ # This ensures the table data is consistent with the chart data
114
+ @ransack_query.result
115
+ .joins(<<~SQL)
116
+ INNER JOIN rails_pulse_summaries ON
117
+ rails_pulse_summaries.summarizable_id = rails_pulse_operations.query_id AND
118
+ rails_pulse_summaries.summarizable_type = 'RailsPulse::Query' AND
119
+ rails_pulse_summaries.period_type = '#{period_type}' AND
120
+ rails_pulse_operations.occurred_at >= rails_pulse_summaries.period_start AND
121
+ rails_pulse_operations.occurred_at < rails_pulse_summaries.period_end
122
+ SQL
123
+ .distinct
76
124
  else
77
- # Optimized query: Use INNER JOIN since we only want queries with operations in time range
78
- # This dramatically reduces the dataset before aggregation
79
- @ransack_query.result(distinct: false)
80
- .joins("INNER JOIN rails_pulse_operations ON rails_pulse_operations.query_id = rails_pulse_queries.id")
81
- .where("rails_pulse_operations.occurred_at >= ? AND rails_pulse_operations.occurred_at < ?",
82
- @table_start_time, @table_end_time)
83
- .group("rails_pulse_queries.id, rails_pulse_queries.normalized_sql, rails_pulse_queries.created_at, rails_pulse_queries.updated_at")
84
- .select(
85
- "rails_pulse_queries.*",
86
- optimized_aggregations_sql
87
- )
125
+ Queries::Tables::Index.new(
126
+ ransack_query: @ransack_query,
127
+ period_type: period_type,
128
+ start_time: @start_time,
129
+ params: params
130
+ ).to_table
88
131
  end
89
132
  end
90
133
 
91
134
  private
92
135
 
93
- def optimized_aggregations_sql
94
- # Efficient aggregations that work with our composite indexes
95
- [
96
- "COALESCE(AVG(rails_pulse_operations.duration), 0) AS average_query_time_ms",
97
- "COUNT(rails_pulse_operations.id) AS execution_count",
98
- "COALESCE(SUM(rails_pulse_operations.duration), 0) AS total_time_consumed",
99
- "MAX(rails_pulse_operations.occurred_at) AS occurred_at"
100
- ].join(", ")
136
+ def setup_metric_cards
137
+ return if turbo_frame_request?
138
+
139
+ @average_query_times_metric_card = RailsPulse::Queries::Cards::AverageQueryTimes.new(query: @query).to_metric_card
140
+ @percentile_query_times_metric_card = RailsPulse::Queries::Cards::PercentileQueryTimes.new(query: @query).to_metric_card
141
+ @execution_rate_metric_card = RailsPulse::Queries::Cards::ExecutionRate.new(query: @query).to_metric_card
101
142
  end
102
143
 
103
144
  def show_action?
@@ -108,14 +149,33 @@ module RailsPulse
108
149
  show_action? ? :set_pagination_limit : :store_pagination_limit
109
150
  end
110
151
 
111
- def set_query
112
- @query = Query.find(params[:id])
152
+ def setup_table_data(ransack_params)
153
+ table_ransack_params = build_table_ransack_params(ransack_params)
154
+ @ransack_query = table_model.ransack(table_ransack_params)
155
+
156
+ # Only apply default sort if not using Queries::Tables::Index (which handles its own sorting)
157
+ if show_action?
158
+ @ransack_query.sorts = default_table_sort if @ransack_query.sorts.empty?
159
+ end
160
+
161
+ table_results = build_table_results
162
+ handle_pagination
163
+
164
+ @pagy, @table_data = pagy(table_results, limit: session_pagination_limit)
165
+ end
166
+
167
+ def handle_pagination
168
+ method = pagination_method
169
+ send(method, params[:limit]) if params[:limit].present?
170
+ end
171
+
172
+ def setup_time_and_response_ranges
173
+ @start_time, @end_time, @selected_time_range, @time_diff_hours = setup_time_range
174
+ @start_duration, @selected_response_range = setup_duration_range(:query)
113
175
  end
114
176
 
115
- def setup_metic_cards
116
- @average_query_times_card = Queries::Cards::AverageQueryTimes.new(query: @query).to_metric_card
117
- @percentile_response_times_card = Queries::Cards::PercentileQueryTimes.new(query: @query).to_metric_card
118
- @execution_rate_card = Queries::Cards::ExecutionRate.new(query: @query).to_metric_card
177
+ def set_query
178
+ @query = Query.find(params[:id])
119
179
  end
120
180
  end
121
181
  end