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
@@ -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
+ }
@@ -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
 
@@ -5,7 +5,11 @@ module RailsPulse
5
5
  def set_pagination_limit(limit = nil)
6
6
  limit = limit || params[:limit]
7
7
  session[:pagination_limit] = limit.to_i if limit.present?
8
- render json: { status: "ok" }
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
9
13
  end
10
14
 
11
15
  private
@@ -2,7 +2,7 @@ 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
8
  setup_metric_cards
@@ -14,6 +14,40 @@ module RailsPulse
14
14
  setup_chart_and_table_data
15
15
  end
16
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
+
17
51
  private
18
52
 
19
53
  def chart_model
@@ -21,7 +55,7 @@ module RailsPulse
21
55
  end
22
56
 
23
57
  def table_model
24
- show_action? ? Operation : Summary
58
+ Summary
25
59
  end
26
60
 
27
61
  def chart_class
@@ -42,7 +76,10 @@ module RailsPulse
42
76
  base_params[:avg_duration_gteq] = @start_duration if @start_duration && @start_duration > 0
43
77
 
44
78
  if show_action?
45
- base_params.merge(summarizable_id_eq: @query.id)
79
+ base_params.merge(
80
+ summarizable_id_eq: @query.id,
81
+ summarizable_type_eq: "RailsPulse::Query"
82
+ )
46
83
  else
47
84
  base_params
48
85
  end
@@ -50,13 +87,14 @@ module RailsPulse
50
87
 
51
88
  def build_table_ransack_params(ransack_params)
52
89
  if show_action?
53
- # For Operation model on show page
90
+ # For Summary model on show page
54
91
  params = ransack_params.merge(
55
- occurred_at_gteq: Time.at(@table_start_time),
56
- occurred_at_lt: Time.at(@table_end_time),
57
- query_id_eq: @query.id
92
+ period_start_gteq: Time.at(@table_start_time),
93
+ period_start_lt: Time.at(@table_end_time),
94
+ summarizable_id_eq: @query.id,
95
+ summarizable_type_eq: "RailsPulse::Query"
58
96
  )
59
- params[:duration_gteq] = @start_duration if @start_duration && @start_duration > 0
97
+ params[:avg_duration_gteq] = @start_duration if @start_duration && @start_duration > 0
60
98
  params
61
99
  else
62
100
  # For Summary model on index page
@@ -70,12 +108,13 @@ module RailsPulse
70
108
  end
71
109
 
72
110
  def default_table_sort
73
- show_action? ? "occurred_at desc" : "period_start desc"
111
+ "period_start desc"
74
112
  end
75
113
 
76
114
  def build_table_results
77
115
  if show_action?
78
- @ransack_query.result
116
+ # For Summary model on show page - ransack params already include query ID and type filters
117
+ @ransack_query.result.where(period_type: period_type)
79
118
  else
80
119
  Queries::Tables::Index.new(
81
120
  ransack_query: @ransack_query,
@@ -5,13 +5,7 @@ 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
+ setup_metric_cards
15
9
  setup_chart_and_table_data
16
10
  end
17
11
 
@@ -21,12 +15,22 @@ module RailsPulse
21
15
 
22
16
  private
23
17
 
18
+ def setup_metric_cards
19
+ return if turbo_frame_request?
20
+
21
+ @average_response_times_metric_card = RailsPulse::Routes::Cards::AverageResponseTimes.new(route: nil).to_metric_card
22
+ @percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(route: nil).to_metric_card
23
+ @request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(route: nil).to_metric_card
24
+ @error_rate_per_route_metric_card = RailsPulse::Routes::Cards::ErrorRatePerRoute.new(route: nil).to_metric_card
25
+ end
26
+
27
+
24
28
  def chart_model
25
- Summary
29
+ RailsPulse::Summary
26
30
  end
27
31
 
28
32
  def table_model
29
- Request
33
+ RailsPulse::Request
30
34
  end
31
35
 
32
36
  def chart_class
@@ -40,7 +44,9 @@ module RailsPulse
40
44
  def build_chart_ransack_params(ransack_params)
41
45
  base_params = ransack_params.except(:s).merge(
42
46
  period_start_gteq: Time.at(@start_time),
43
- period_start_lt: Time.at(@end_time)
47
+ period_start_lt: Time.at(@end_time),
48
+ summarizable_type_eq: "RailsPulse::Request",
49
+ summarizable_id_eq: 0
44
50
  )
45
51
 
46
52
  # Only add duration filter if we have a meaningful threshold
@@ -62,16 +68,36 @@ module RailsPulse
62
68
  end
63
69
 
64
70
  def build_table_results
65
- @ransack_query.result
66
- .joins(:route)
67
- .select(
68
- "rails_pulse_requests.id",
69
- "rails_pulse_requests.occurred_at",
70
- "rails_pulse_requests.duration",
71
- "rails_pulse_requests.status",
72
- "rails_pulse_requests.route_id",
73
- "rails_pulse_routes.path"
74
- )
71
+ base_query = @ransack_query.result.includes(:route)
72
+
73
+ # If sorting by route_path, we need to join the routes table
74
+ if @ransack_query.sorts.any? { |sort| sort.name == "route_path" }
75
+ base_query = base_query.joins(:route)
76
+ end
77
+
78
+ base_query
79
+ end
80
+
81
+
82
+ def setup_table_data(ransack_params)
83
+ table_ransack_params = build_table_ransack_params(ransack_params)
84
+ @ransack_query = table_model.ransack(table_ransack_params)
85
+
86
+ # Only apply default sort if not using Requests::Tables::Index (which handles its own sorting)
87
+ # For requests, we always use the Tables::Index on the index action
88
+ unless action_name == "index"
89
+ @ransack_query.sorts = default_table_sort if @ransack_query.sorts.empty?
90
+ end
91
+
92
+ table_results = build_table_results
93
+ handle_pagination
94
+
95
+ @pagy, @table_data = pagy(table_results, limit: session_pagination_limit)
96
+ end
97
+
98
+ def handle_pagination
99
+ method = pagination_method
100
+ send(method, params[:limit]) if params[:limit].present?
75
101
  end
76
102
 
77
103
  def set_request
@@ -84,7 +84,25 @@ module RailsPulse
84
84
 
85
85
  def build_table_results
86
86
  if show_action?
87
- @ransack_query.result
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
88
106
  else
89
107
  Routes::Tables::Index.new(
90
108
  ransack_query: @ransack_query,
@@ -130,5 +148,26 @@ module RailsPulse
130
148
  def set_route
131
149
  @route = Route.find(params[:id])
132
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
133
172
  end
134
173
  end
@@ -25,7 +25,7 @@ module RailsPulse
25
25
 
26
26
  return crumbs if path_segments.empty?
27
27
 
28
- current_path = "/rails_pulse"
28
+ current_path = main_app.rails_pulse_path.chomp("/")
29
29
 
30
30
  path_segments.each_with_index do |segment, index|
31
31
  current_path += "/#{segment}"
@@ -27,7 +27,7 @@ module RailsPulse
27
27
  top: "10%",
28
28
  containLabel: true
29
29
  },
30
- animation: false
30
+ animation: true
31
31
  }
32
32
  end
33
33
 
@@ -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%"
73
+ },
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 }
72
82
  },
73
- yAxis: { show: false },
74
- xAxis: { splitLine: { show: false } },
75
- grid: { show: false }
83
+ grid: { left: 0, right: 0, top: 0, bottom: 0, containLabel: false, show: false }
76
84
  })
77
85
  end
78
86
 
@@ -3,7 +3,8 @@ module RailsPulse
3
3
  def human_readable_occurred_at(occurred_at)
4
4
  return "" unless occurred_at.present?
5
5
  time = occurred_at.is_a?(String) ? Time.parse(occurred_at) : occurred_at
6
- time.strftime("%b %d, %Y %l:%M %p")
6
+ # Convert to local system timezone (same as charts use)
7
+ time.getlocal.strftime("%b %d, %Y %l:%M %p")
7
8
  end
8
9
 
9
10
  def time_ago_in_words(time)
@@ -11,8 +12,10 @@ module RailsPulse
11
12
 
12
13
  # Convert to Time object if it's a string
13
14
  time = Time.parse(time.to_s) if time.is_a?(String)
15
+ # Convert to local system timezone for consistent calculation
16
+ time = time.getlocal
14
17
 
15
- seconds_ago = Time.current - time
18
+ seconds_ago = Time.now - time
16
19
 
17
20
  case seconds_ago
18
21
  when 0..59
@@ -25,5 +28,21 @@ module RailsPulse
25
28
  "#{(seconds_ago / 86400).to_i}d ago"
26
29
  end
27
30
  end
31
+
32
+ def human_readable_summary_period(summary)
33
+ return "" unless summary&.period_start&.present? && summary&.period_end&.present?
34
+
35
+ # Convert UTC times to local system timezone to match chart display
36
+ start_time = summary.period_start.getlocal
37
+ end_time = summary.period_end.getlocal
38
+
39
+
40
+ case summary.period_type
41
+ when "hour"
42
+ start_time.strftime("%b %e %Y, %l:%M %p") + " - " + end_time.strftime("%l:%M %p")
43
+ when "day"
44
+ start_time.strftime("%b %e, %Y")
45
+ end
46
+ end
28
47
  end
29
48
  end
@@ -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
+ }