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
@@ -5,6 +5,13 @@ module RailsPulse
5
5
  before_action :set_request, only: :show
6
6
 
7
7
  def index
8
+ unless turbo_frame_request?
9
+ @average_response_times_metric_card = RailsPulse::Routes::Cards::AverageResponseTimes.new(route: nil).to_metric_card
10
+ @percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(route: nil).to_metric_card
11
+ @request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(route: nil).to_metric_card
12
+ @error_rate_per_route_metric_card = RailsPulse::Routes::Cards::ErrorRatePerRoute.new(route: nil).to_metric_card
13
+ end
14
+
8
15
  setup_chart_and_table_data
9
16
  end
10
17
 
@@ -15,7 +22,7 @@ module RailsPulse
15
22
  private
16
23
 
17
24
  def chart_model
18
- Request
25
+ Summary
19
26
  end
20
27
 
21
28
  def table_model
@@ -27,23 +34,29 @@ module RailsPulse
27
34
  end
28
35
 
29
36
  def chart_options
30
- { route: true }
37
+ {}
31
38
  end
32
39
 
33
40
  def build_chart_ransack_params(ransack_params)
34
- ransack_params.except(:s).merge(
35
- occurred_at_gteq: @start_time,
36
- occurred_at_lt: @end_time,
37
- duration_gteq: @start_duration
41
+ base_params = ransack_params.except(:s).merge(
42
+ period_start_gteq: Time.at(@start_time),
43
+ period_start_lt: Time.at(@end_time),
44
+ summarizable_type_eq: "RailsPulse::Request",
45
+ summarizable_id_eq: 0
38
46
  )
47
+
48
+ # Only add duration filter if we have a meaningful threshold
49
+ base_params[:avg_duration_gteq] = @start_duration if @start_duration && @start_duration > 0
50
+ base_params
39
51
  end
40
52
 
41
53
  def build_table_ransack_params(ransack_params)
42
- ransack_params.merge(
43
- occurred_at_gteq: @table_start_time,
44
- occurred_at_lt: @table_end_time,
45
- duration_gteq: @start_duration
54
+ params = ransack_params.merge(
55
+ occurred_at_gteq: Time.at(@table_start_time),
56
+ occurred_at_lt: Time.at(@table_end_time)
46
57
  )
58
+ params[:duration_gteq] = @start_duration if @start_duration && @start_duration > 0
59
+ params
47
60
  end
48
61
 
49
62
  def default_table_sort
@@ -51,15 +64,27 @@ module RailsPulse
51
64
  end
52
65
 
53
66
  def build_table_results
67
+ # Only show requests that belong to time periods where we have overall request summaries
68
+ # This ensures the table data is consistent with the chart data
54
69
  @ransack_query.result
55
- .includes(:route)
70
+ .joins(:route)
71
+ .joins(<<~SQL)
72
+ INNER JOIN rails_pulse_summaries ON
73
+ rails_pulse_summaries.summarizable_id = 0 AND
74
+ rails_pulse_summaries.summarizable_type = 'RailsPulse::Request' AND
75
+ rails_pulse_summaries.period_type = '#{period_type}' AND
76
+ rails_pulse_requests.occurred_at >= rails_pulse_summaries.period_start AND
77
+ rails_pulse_requests.occurred_at < rails_pulse_summaries.period_end
78
+ SQL
56
79
  .select(
57
80
  "rails_pulse_requests.id",
58
81
  "rails_pulse_requests.occurred_at",
59
82
  "rails_pulse_requests.duration",
60
83
  "rails_pulse_requests.status",
61
- "rails_pulse_requests.route_id"
84
+ "rails_pulse_requests.route_id",
85
+ "rails_pulse_routes.path"
62
86
  )
87
+ .distinct
63
88
  end
64
89
 
65
90
  def set_request
@@ -5,21 +5,32 @@ module RailsPulse
5
5
  before_action :set_route, only: :show
6
6
 
7
7
  def index
8
+ setup_metric_cards
8
9
  setup_chart_and_table_data
9
10
  end
10
11
 
11
12
  def show
13
+ setup_metric_cards
12
14
  setup_chart_and_table_data
13
15
  end
14
16
 
15
17
  private
16
18
 
19
+ def setup_metric_cards
20
+ return if turbo_frame_request?
21
+
22
+ @average_query_times_metric_card = RailsPulse::Routes::Cards::AverageResponseTimes.new(route: @route).to_metric_card
23
+ @percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(route: @route).to_metric_card
24
+ @request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(route: @route).to_metric_card
25
+ @error_rate_per_route_metric_card = RailsPulse::Routes::Cards::ErrorRatePerRoute.new(route: @route).to_metric_card
26
+ end
27
+
17
28
  def chart_model
18
- show_action? ? Request : Route
29
+ Summary
19
30
  end
20
31
 
21
32
  def table_model
22
- show_action? ? Request : Route
33
+ show_action? ? Request : Summary
23
34
  end
24
35
 
25
36
  def chart_class
@@ -31,49 +42,71 @@ module RailsPulse
31
42
  end
32
43
 
33
44
  def build_chart_ransack_params(ransack_params)
34
- base_params = ransack_params.except(:s).merge(duration_field => @start_duration)
45
+ base_params = ransack_params.except(:s).merge(
46
+ period_start_gteq: Time.at(@start_time),
47
+ period_start_lt: Time.at(@end_time)
48
+ )
49
+
50
+ # Only add duration filter if we have a meaningful threshold
51
+ base_params[:avg_duration_gteq] = @start_duration if @start_duration && @start_duration > 0
35
52
 
36
53
  if show_action?
37
- base_params.merge(
38
- route_id_eq: @route.id,
39
- occurred_at_gteq: @start_time,
40
- occurred_at_lt: @end_time
41
- )
54
+ base_params.merge(summarizable_id_eq: @route.id)
42
55
  else
43
- base_params.merge(
44
- requests_occurred_at_gteq: @start_time,
45
- requests_occurred_at_lt: @end_time
46
- )
56
+ base_params
47
57
  end
48
58
  end
49
59
 
50
60
  def build_table_ransack_params(ransack_params)
51
- base_params = ransack_params.merge(duration_field => @start_duration)
52
-
53
61
  if show_action?
54
- base_params.merge(
55
- route_id_eq: @route.id,
56
- occurred_at_gteq: @table_start_time,
57
- occurred_at_lt: @table_end_time
62
+ # For Request model on show page
63
+ params = ransack_params.merge(
64
+ occurred_at_gteq: Time.at(@table_start_time),
65
+ occurred_at_lt: Time.at(@table_end_time),
66
+ route_id_eq: @route.id
58
67
  )
68
+ params[:duration_gteq] = @start_duration if @start_duration && @start_duration > 0
69
+ params
59
70
  else
60
- base_params.merge(
61
- requests_occurred_at_gteq: @table_start_time,
62
- requests_occurred_at_lt: @table_end_time
71
+ # For Summary model on index page
72
+ params = ransack_params.merge(
73
+ period_start_gteq: Time.at(@table_start_time),
74
+ period_start_lt: Time.at(@table_end_time)
63
75
  )
76
+ params[:avg_duration_gteq] = @start_duration if @start_duration && @start_duration > 0
77
+ params
64
78
  end
65
79
  end
66
80
 
67
81
  def default_table_sort
68
- show_action? ? "occurred_at desc" : "average_response_time_ms desc"
82
+ show_action? ? "occurred_at desc" : "avg_duration desc"
69
83
  end
70
84
 
71
85
  def build_table_results
72
86
  if show_action?
73
- @ransack_query.result.select("id", "route_id", "occurred_at", "duration", "status")
87
+ # Only show requests that belong to time periods where we have route summaries
88
+ # This ensures the table data is consistent with the chart data
89
+ base_query = @ransack_query.result
90
+ .joins(<<~SQL)
91
+ INNER JOIN rails_pulse_summaries ON
92
+ rails_pulse_summaries.summarizable_id = rails_pulse_requests.route_id AND
93
+ rails_pulse_summaries.summarizable_type = 'RailsPulse::Route' AND
94
+ rails_pulse_summaries.period_type = '#{period_type}' AND
95
+ rails_pulse_requests.occurred_at >= rails_pulse_summaries.period_start AND
96
+ rails_pulse_requests.occurred_at < rails_pulse_summaries.period_end
97
+ SQL
98
+
99
+ # For PostgreSQL compatibility with DISTINCT + ORDER BY
100
+ # we need to include computed columns in SELECT when ordering by them
101
+ if ordering_by_computed_column?
102
+ base_query.select("rails_pulse_requests.*, #{status_indicator_sql} as status_indicator_value").distinct
103
+ else
104
+ base_query.distinct
105
+ end
74
106
  else
75
107
  Routes::Tables::Index.new(
76
108
  ransack_query: @ransack_query,
109
+ period_type: period_type,
77
110
  start_time: @start_time,
78
111
  params: params
79
112
  ).to_table
@@ -81,13 +114,33 @@ module RailsPulse
81
114
  end
82
115
 
83
116
  def duration_field
84
- show_action? ? :duration_gteq : :requests_duration_gteq
117
+ :avg_duration
85
118
  end
86
119
 
87
120
  def show_action?
88
121
  action_name == "show"
89
122
  end
90
123
 
124
+ def setup_table_data(ransack_params)
125
+ table_ransack_params = build_table_ransack_params(ransack_params)
126
+ @ransack_query = table_model.ransack(table_ransack_params)
127
+
128
+ # Only apply default sort if not using Routes::Tables::Index (which handles its own sorting)
129
+ if show_action?
130
+ @ransack_query.sorts = default_table_sort if @ransack_query.sorts.empty?
131
+ end
132
+
133
+ table_results = build_table_results
134
+ handle_pagination
135
+
136
+ @pagy, @table_data = pagy(table_results, limit: session_pagination_limit)
137
+ end
138
+
139
+ def handle_pagination
140
+ method = pagination_method
141
+ send(method, params[:limit]) if params[:limit].present?
142
+ end
143
+
91
144
  def pagination_method
92
145
  show_action? ? :set_pagination_limit : :store_pagination_limit
93
146
  end
@@ -95,5 +148,26 @@ module RailsPulse
95
148
  def set_route
96
149
  @route = Route.find(params[:id])
97
150
  end
151
+
152
+ def ordering_by_computed_column?
153
+ # Check if we're ordering by status_indicator (computed column)
154
+ @ransack_query.sorts.any? { |sort| sort.name == "status_indicator" }
155
+ end
156
+
157
+ def status_indicator_sql
158
+ # Same logic as in the Request model's ransacker
159
+ config = RailsPulse.configuration rescue nil
160
+ thresholds = config&.request_thresholds || { slow: 500, very_slow: 1000, critical: 2000 }
161
+ slow = thresholds[:slow] || 500
162
+ very_slow = thresholds[:very_slow] || 1000
163
+ critical = thresholds[:critical] || 2000
164
+
165
+ "CASE
166
+ WHEN rails_pulse_requests.duration < #{slow} THEN 0
167
+ WHEN rails_pulse_requests.duration < #{very_slow} THEN 1
168
+ WHEN rails_pulse_requests.duration < #{critical} THEN 2
169
+ ELSE 3
170
+ END"
171
+ end
98
172
  end
99
173
  end
@@ -3,7 +3,6 @@ module RailsPulse
3
3
  include Pagy::Frontend
4
4
 
5
5
  include BreadcrumbsHelper
6
- include CachedComponentHelper
7
6
  include ChartHelper
8
7
  include FormattingHelper
9
8
  include StatusHelper
@@ -1,6 +1,6 @@
1
1
  module RailsPulse
2
2
  module ChartFormatters
3
- def self.occurred_at_as_time_or_date(time_diff_hours)
3
+ def self.period_as_time_or_date(time_diff_hours)
4
4
  if time_diff_hours <= 25
5
5
  <<~JS
6
6
  function(value) {
@@ -25,7 +25,7 @@ module RailsPulse
25
25
  const data = params[0];
26
26
  const date = new Date(data.axisValue * 1000);
27
27
  const dateString = date.getHours().toString().padStart(2, '0') + ':00';
28
- return `${dateString} <br /> ${data.marker} ${parseInt(data.data.value)} ms`;
28
+ return `${dateString} <br /> ${data.marker} ${parseInt(data.data)} ms`;
29
29
  }
30
30
  JS
31
31
  else
@@ -34,7 +34,7 @@ module RailsPulse
34
34
  const data = params[0];
35
35
  const date = new Date(data.axisValue * 1000);
36
36
  const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
37
- return `${dateString} <br /> ${data.marker} ${parseInt(data.data.value)} ms`;
37
+ return `${dateString} <br /> ${data.marker} ${parseInt(data.data)} ms`;
38
38
  }
39
39
  JS
40
40
  end
@@ -63,16 +63,24 @@ module RailsPulse
63
63
  end
64
64
 
65
65
  def sparkline_chart_options
66
+ # Compact sparkline columns that fill the canvas with no axes/labels/gaps
66
67
  base_chart_options.deep_merge({
67
68
  series: {
68
- type: "line",
69
- smooth: true,
70
- lineStyle: { width: 2 },
71
- symbol: "none"
69
+ type: "bar",
70
+ itemStyle: { borderRadius: [ 2, 2, 0, 0 ] },
71
+ barCategoryGap: "10%",
72
+ barGap: "0%"
72
73
  },
73
- yAxis: { show: false },
74
- xAxis: { splitLine: { show: false } },
75
- grid: { show: false }
74
+ yAxis: { show: false, splitLine: { show: false } },
75
+ xAxis: {
76
+ type: "category",
77
+ boundaryGap: true,
78
+ axisLine: { show: false },
79
+ axisTick: { show: false },
80
+ splitLine: { show: false },
81
+ axisLabel: { show: false }
82
+ },
83
+ grid: { left: 0, right: 0, top: 0, bottom: 0, containLabel: false, show: false }
76
84
  })
77
85
  end
78
86
 
@@ -117,9 +125,13 @@ module RailsPulse
117
125
  # Chart data is a hash like: { 1234567890 => { value: 123.45 } }
118
126
  chart_timestamps = chart_data.keys
119
127
 
128
+ # Convert zoom parameters to integers (timestamps)
129
+ zoom_start_int = zoom_start.respond_to?(:to_i) ? zoom_start.to_i : zoom_start
130
+ zoom_end_int = zoom_end.respond_to?(:to_i) ? zoom_end.to_i : zoom_end
131
+
120
132
  if chart_timestamps.any?
121
- closest_start = chart_timestamps.min_by { |ts| (ts - zoom_start).abs }
122
- closest_end = chart_timestamps.min_by { |ts| (ts - zoom_end).abs }
133
+ closest_start = chart_timestamps.min_by { |ts| (ts - zoom_start_int).abs }
134
+ closest_end = chart_timestamps.min_by { |ts| (ts - zoom_end_int).abs }
123
135
 
124
136
  # Find the array indices of these timestamps
125
137
  start_index = chart_timestamps.index(closest_start)
@@ -256,13 +256,19 @@ module RailsPulse
256
256
 
257
257
  def event_color(operation_type)
258
258
  case operation_type
259
- when "sql" then "#92c282;"
260
- when "template", "partial", "layout", "collection" then "#b77cbf"
261
- when "controller" then "#00adc4"
262
- else "gray"
259
+ when "sql"
260
+ "#d27d6b"
261
+ when "template", "partial", "layout", "collection"
262
+ "#6c7ab9"
263
+ when "controller"
264
+ "#5ba6b0"
265
+ else
266
+ "#a6a6a6"
263
267
  end
264
268
  end
265
269
 
270
+
271
+
266
272
  def duration_options(type = :route)
267
273
  thresholds = RailsPulse.configuration.public_send("#{type}_thresholds")
268
274
 
@@ -16,7 +16,9 @@ import ColorSchemeController from "./controllers/color_scheme_controller";
16
16
  import PaginationController from "./controllers/pagination_controller";
17
17
  import TimezoneController from "./controllers/timezone_controller";
18
18
  import IconController from "./controllers/icon_controller";
19
- import ExpandableRowController from "./controllers/expandable_row_controller";
19
+ import ExpandableRowsController from "./controllers/expandable_rows_controller";
20
+ import CollapsibleController from "./controllers/collapsible_controller";
21
+ import TableSortController from "./controllers/table_sort_controller";
20
22
 
21
23
  const application = Application.start();
22
24
 
@@ -41,7 +43,9 @@ application.register("rails-pulse--color-scheme", ColorSchemeController);
41
43
  application.register("rails-pulse--pagination", PaginationController);
42
44
  application.register("rails-pulse--timezone", TimezoneController);
43
45
  application.register("rails-pulse--icon", IconController);
44
- application.register("rails-pulse--expandable-row", ExpandableRowController);
46
+ application.register("rails-pulse--expandable-rows", ExpandableRowsController);
47
+ application.register("rails-pulse--collapsible", CollapsibleController);
48
+ application.register("rails-pulse--table-sort", TableSortController);
45
49
 
46
50
  // Ensure Turbo Frames are loaded after page load
47
51
  document.addEventListener('DOMContentLoaded', () => {
@@ -95,6 +99,32 @@ window.addEventListener('resize', function() {
95
99
  }
96
100
  });
97
101
 
102
+ // Apply axis label colors based on current color scheme
103
+ function applyChartAxisLabelColors() {
104
+ if (!window.RailsCharts || !window.RailsCharts.charts) return;
105
+ const scheme = document.documentElement.getAttribute('data-color-scheme');
106
+ const isDark = scheme === 'dark';
107
+ const axisColor = isDark ? '#ffffff' : '#999999';
108
+ Object.keys(window.RailsCharts.charts).forEach(function(chartID) {
109
+ const chart = window.RailsCharts.charts[chartID];
110
+ try {
111
+ chart.setOption({
112
+ xAxis: { axisLabel: { color: axisColor } },
113
+ yAxis: { axisLabel: { color: axisColor } }
114
+ });
115
+ } catch (e) {
116
+ // noop
117
+ }
118
+ });
119
+ }
120
+
121
+ // Initial apply after charts initialize and on scheme changes
122
+ document.addEventListener('DOMContentLoaded', () => {
123
+ // run shortly after load to allow charts to initialize
124
+ setTimeout(applyChartAxisLabelColors, 50);
125
+ });
126
+ document.addEventListener('rails-pulse:color-scheme-changed', applyChartAxisLabelColors);
127
+
98
128
  // Global function to initialize Rails Charts in any container.
99
129
  // This is needed as we render Rails Charts in Turbo Frames.
100
130
  window.initializeChartsInContainer = function(containerId) {
@@ -108,6 +138,8 @@ window.initializeChartsInContainer = function(containerId) {
108
138
  window[match[1]]();
109
139
  }
110
140
  });
141
+ // ensure colors are correct for any charts initialized in this container
142
+ setTimeout(applyChartAxisLabelColors, 10);
111
143
  });
112
144
  };
113
145
 
@@ -116,4 +148,3 @@ window.RailsPulse = {
116
148
  application,
117
149
  version: "1.0.0"
118
150
  };
119
-
@@ -0,0 +1,32 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["content", "toggle"]
5
+ static classes = ["collapsed"]
6
+
7
+ connect() {
8
+ this.collapse()
9
+ }
10
+
11
+ toggle() {
12
+ if (this.element.classList.contains(this.collapsedClass)) {
13
+ this.expand()
14
+ } else {
15
+ this.collapse()
16
+ }
17
+ }
18
+
19
+ collapse() {
20
+ this.element.classList.add(this.collapsedClass)
21
+ if (this.hasToggleTarget) {
22
+ this.toggleTarget.textContent = "show more"
23
+ }
24
+ }
25
+
26
+ expand() {
27
+ this.element.classList.remove(this.collapsedClass)
28
+ if (this.hasToggleTarget) {
29
+ this.toggleTarget.textContent = "show less"
30
+ }
31
+ }
32
+ }
@@ -13,8 +13,9 @@ export default class extends Controller {
13
13
  toggle(event) {
14
14
  event.preventDefault()
15
15
  const current = this.html.getAttribute("data-color-scheme") === "dark" ? "light" : "dark"
16
- console.log("Toggling color scheme to", current)
17
16
  this.html.setAttribute("data-color-scheme", current)
18
17
  localStorage.setItem(this.storageKey, current)
18
+ // Notify listeners (e.g., charts) that scheme changed
19
+ document.dispatchEvent(new CustomEvent('rails-pulse:color-scheme-changed', { detail: { scheme: current }}))
19
20
  }
20
21
  }
@@ -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
+ }