rails_pulse 0.1.0

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 (160) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +638 -0
  4. data/Rakefile +207 -0
  5. data/app/assets/images/rails_pulse/dashboard.png +0 -0
  6. data/app/assets/images/rails_pulse/menu.svg +1 -0
  7. data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
  8. data/app/assets/images/rails_pulse/request.png +0 -0
  9. data/app/assets/images/rails_pulse/routes.png +0 -0
  10. data/app/assets/stylesheets/rails_pulse/application.css +102 -0
  11. data/app/assets/stylesheets/rails_pulse/components/alert.css +24 -0
  12. data/app/assets/stylesheets/rails_pulse/components/badge.css +58 -0
  13. data/app/assets/stylesheets/rails_pulse/components/base.css +79 -0
  14. data/app/assets/stylesheets/rails_pulse/components/breadcrumb.css +31 -0
  15. data/app/assets/stylesheets/rails_pulse/components/button.css +99 -0
  16. data/app/assets/stylesheets/rails_pulse/components/card.css +19 -0
  17. data/app/assets/stylesheets/rails_pulse/components/chart.css +18 -0
  18. data/app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css +86 -0
  19. data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +9 -0
  20. data/app/assets/stylesheets/rails_pulse/components/dialog.css +56 -0
  21. data/app/assets/stylesheets/rails_pulse/components/flash.css +47 -0
  22. data/app/assets/stylesheets/rails_pulse/components/input.css +80 -0
  23. data/app/assets/stylesheets/rails_pulse/components/layouts.css +63 -0
  24. data/app/assets/stylesheets/rails_pulse/components/menu.css +43 -0
  25. data/app/assets/stylesheets/rails_pulse/components/popover.css +36 -0
  26. data/app/assets/stylesheets/rails_pulse/components/prose.css +144 -0
  27. data/app/assets/stylesheets/rails_pulse/components/row.css +24 -0
  28. data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +79 -0
  29. data/app/assets/stylesheets/rails_pulse/components/skeleton.css +5 -0
  30. data/app/assets/stylesheets/rails_pulse/components/table.css +37 -0
  31. data/app/assets/stylesheets/rails_pulse/components/utilities.css +36 -0
  32. data/app/controllers/concerns/chart_table_concern.rb +82 -0
  33. data/app/controllers/concerns/response_range_concern.rb +24 -0
  34. data/app/controllers/concerns/time_range_concern.rb +67 -0
  35. data/app/controllers/concerns/zoom_range_concern.rb +40 -0
  36. data/app/controllers/rails_pulse/application_controller.rb +67 -0
  37. data/app/controllers/rails_pulse/assets_controller.rb +33 -0
  38. data/app/controllers/rails_pulse/caches_controller.rb +115 -0
  39. data/app/controllers/rails_pulse/csp_test_controller.rb +57 -0
  40. data/app/controllers/rails_pulse/dashboard_controller.rb +6 -0
  41. data/app/controllers/rails_pulse/operations_controller.rb +219 -0
  42. data/app/controllers/rails_pulse/queries_controller.rb +121 -0
  43. data/app/controllers/rails_pulse/requests_controller.rb +69 -0
  44. data/app/controllers/rails_pulse/routes_controller.rb +99 -0
  45. data/app/helpers/rails_pulse/application_helper.rb +111 -0
  46. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +62 -0
  47. data/app/helpers/rails_pulse/cached_component_helper.rb +73 -0
  48. data/app/helpers/rails_pulse/chart_formatters.rb +43 -0
  49. data/app/helpers/rails_pulse/chart_helper.rb +140 -0
  50. data/app/helpers/rails_pulse/formatting_helper.rb +29 -0
  51. data/app/helpers/rails_pulse/status_helper.rb +279 -0
  52. data/app/helpers/rails_pulse/table_helper.rb +54 -0
  53. data/app/javascript/rails_pulse/application.js +119 -0
  54. data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +20 -0
  55. data/app/javascript/rails_pulse/controllers/context_menu_controller.js +16 -0
  56. data/app/javascript/rails_pulse/controllers/dialog_controller.js +21 -0
  57. data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +67 -0
  58. data/app/javascript/rails_pulse/controllers/form_controller.js +39 -0
  59. data/app/javascript/rails_pulse/controllers/icon_controller.js +170 -0
  60. data/app/javascript/rails_pulse/controllers/index_controller.js +230 -0
  61. data/app/javascript/rails_pulse/controllers/menu_controller.js +60 -0
  62. data/app/javascript/rails_pulse/controllers/pagination_controller.js +69 -0
  63. data/app/javascript/rails_pulse/controllers/popover_controller.js +91 -0
  64. data/app/javascript/rails_pulse/controllers/timezone_controller.js +106 -0
  65. data/app/javascript/rails_pulse/theme.js +416 -0
  66. data/app/jobs/rails_pulse/application_job.rb +4 -0
  67. data/app/jobs/rails_pulse/cleanup_job.rb +21 -0
  68. data/app/mailers/rails_pulse/application_mailer.rb +6 -0
  69. data/app/models/rails_pulse/application_record.rb +7 -0
  70. data/app/models/rails_pulse/component_cache_key.rb +33 -0
  71. data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +27 -0
  72. data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +37 -0
  73. data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +59 -0
  74. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +45 -0
  75. data/app/models/rails_pulse/operation.rb +87 -0
  76. data/app/models/rails_pulse/queries/cards/average_query_times.rb +52 -0
  77. data/app/models/rails_pulse/queries/cards/execution_rate.rb +57 -0
  78. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +71 -0
  79. data/app/models/rails_pulse/queries/charts/average_query_times.rb +112 -0
  80. data/app/models/rails_pulse/query.rb +58 -0
  81. data/app/models/rails_pulse/request.rb +64 -0
  82. data/app/models/rails_pulse/requests/charts/average_response_times.rb +99 -0
  83. data/app/models/rails_pulse/requests/charts/operations_chart.rb +35 -0
  84. data/app/models/rails_pulse/route.rb +77 -0
  85. data/app/models/rails_pulse/routes/cards/average_response_times.rb +54 -0
  86. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +73 -0
  87. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +73 -0
  88. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +59 -0
  89. data/app/models/rails_pulse/routes/charts/average_response_times.rb +115 -0
  90. data/app/models/rails_pulse/routes/tables/index.rb +63 -0
  91. data/app/services/rails_pulse/sql_query_normalizer.rb +124 -0
  92. data/app/views/layouts/rails_pulse/_menu_items.html.erb +19 -0
  93. data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +44 -0
  94. data/app/views/layouts/rails_pulse/application.html.erb +72 -0
  95. data/app/views/rails_pulse/caches/show.html.erb +9 -0
  96. data/app/views/rails_pulse/components/_breadcrumbs.html.erb +12 -0
  97. data/app/views/rails_pulse/components/_code_panel.html.erb +12 -0
  98. data/app/views/rails_pulse/components/_metric_card.html.erb +55 -0
  99. data/app/views/rails_pulse/components/_metric_row.html.erb +9 -0
  100. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +241 -0
  101. data/app/views/rails_pulse/components/_panel.html.erb +56 -0
  102. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +15 -0
  103. data/app/views/rails_pulse/components/_table.html.erb +50 -0
  104. data/app/views/rails_pulse/components/_table_head.html.erb +20 -0
  105. data/app/views/rails_pulse/components/_table_pagination.html.erb +45 -0
  106. data/app/views/rails_pulse/components/_time_period.html.erb +16 -0
  107. data/app/views/rails_pulse/csp_test/show.html.erb +207 -0
  108. data/app/views/rails_pulse/dashboard/charts/_bar_chart.html.erb +1 -0
  109. data/app/views/rails_pulse/dashboard/index.html.erb +64 -0
  110. data/app/views/rails_pulse/dashboard/tables/_routes_table.html.erb +32 -0
  111. data/app/views/rails_pulse/dashboard/tables/_standard_table.html.erb +1 -0
  112. data/app/views/rails_pulse/operations/_operation_analysis_application.html.erb +43 -0
  113. data/app/views/rails_pulse/operations/_operation_analysis_database.html.erb +12 -0
  114. data/app/views/rails_pulse/operations/_operation_analysis_generic.html.erb +15 -0
  115. data/app/views/rails_pulse/operations/_operation_analysis_other.html.erb +69 -0
  116. data/app/views/rails_pulse/operations/_operation_analysis_view.html.erb +39 -0
  117. data/app/views/rails_pulse/operations/show.html.erb +79 -0
  118. data/app/views/rails_pulse/queries/_show_table.html.erb +19 -0
  119. data/app/views/rails_pulse/queries/_table.html.erb +31 -0
  120. data/app/views/rails_pulse/queries/index.html.erb +64 -0
  121. data/app/views/rails_pulse/queries/show.html.erb +86 -0
  122. data/app/views/rails_pulse/requests/_operations.html.erb +85 -0
  123. data/app/views/rails_pulse/requests/_table.html.erb +31 -0
  124. data/app/views/rails_pulse/requests/index.html.erb +64 -0
  125. data/app/views/rails_pulse/requests/show.html.erb +44 -0
  126. data/app/views/rails_pulse/routes/_table.html.erb +29 -0
  127. data/app/views/rails_pulse/routes/index.html.erb +65 -0
  128. data/app/views/rails_pulse/routes/show.html.erb +67 -0
  129. data/app/views/rails_pulse/skeletons/_chart.html.erb +3 -0
  130. data/app/views/rails_pulse/skeletons/_metric_card.html.erb +20 -0
  131. data/app/views/rails_pulse/skeletons/_panel.html.erb +19 -0
  132. data/app/views/rails_pulse/skeletons/_table.html.erb +8 -0
  133. data/config/importmap.rb +12 -0
  134. data/config/initializers/rails_charts_csp_patch.rb +83 -0
  135. data/config/initializers/rails_pulse.rb +198 -0
  136. data/config/routes.rb +16 -0
  137. data/db/migrate/20250227235904_create_routes.rb +12 -0
  138. data/db/migrate/20250227235915_create_requests.rb +19 -0
  139. data/db/migrate/20250228000000_create_queries.rb +14 -0
  140. data/db/migrate/20250228000056_create_operations.rb +24 -0
  141. data/lib/generators/rails_pulse/install_generator.rb +17 -0
  142. data/lib/generators/rails_pulse/templates/rails_pulse.rb +198 -0
  143. data/lib/rails_pulse/cleanup_service.rb +212 -0
  144. data/lib/rails_pulse/configuration.rb +176 -0
  145. data/lib/rails_pulse/engine.rb +88 -0
  146. data/lib/rails_pulse/middleware/asset_server.rb +84 -0
  147. data/lib/rails_pulse/middleware/request_collector.rb +120 -0
  148. data/lib/rails_pulse/migration.rb +29 -0
  149. data/lib/rails_pulse/subscribers/operation_subscriber.rb +280 -0
  150. data/lib/rails_pulse/version.rb +3 -0
  151. data/lib/rails_pulse.rb +38 -0
  152. data/lib/tasks/rails_pulse_tasks.rake +138 -0
  153. data/public/rails-pulse-assets/csp-test.js +110 -0
  154. data/public/rails-pulse-assets/rails-pulse-icons.js +89 -0
  155. data/public/rails-pulse-assets/rails-pulse-icons.js.map +13 -0
  156. data/public/rails-pulse-assets/rails-pulse.css +1 -0
  157. data/public/rails-pulse-assets/rails-pulse.css.map +1 -0
  158. data/public/rails-pulse-assets/rails-pulse.js +183 -0
  159. data/public/rails-pulse-assets/rails-pulse.js.map +7 -0
  160. metadata +339 -0
@@ -0,0 +1,37 @@
1
+ :where(.table) {
2
+ caption-side: bottom;
3
+ font-size: var(--text-sm);
4
+ inline-size: var(--size-full);
5
+
6
+ caption {
7
+ color: var(--color-text-subtle);
8
+ margin-block-start: var(--size-4);
9
+ }
10
+
11
+ thead {
12
+ color: var(--color-text-subtle);
13
+ }
14
+
15
+ tbody tr {
16
+ border-block-start-width: var(--border);
17
+ }
18
+
19
+ tr:hover {
20
+ background-color: rgb(from var(--color-border-light) r g b / .5);
21
+ }
22
+
23
+ th {
24
+ font-weight: var(--font-medium);
25
+ text-align: start;
26
+ }
27
+
28
+ th, td {
29
+ padding: var(--size-2);
30
+ }
31
+
32
+ tfoot {
33
+ background-color: rgb(from var(--color-border-light) r g b / .5);
34
+ border-block-start-width: var(--border);
35
+ font-weight: var(--font-medium);
36
+ }
37
+ }
@@ -0,0 +1,36 @@
1
+ /* Width utilities */
2
+ .w-auto { width: auto; }
3
+ .w-4 { width: 1rem; }
4
+ .w-6 { width: 1.5rem; }
5
+ .w-8 { width: 2rem; }
6
+ .w-12 { width: 3rem; }
7
+ .w-16 { width: 4rem; }
8
+ .w-20 { width: 5rem; }
9
+ .w-24 { width: 6rem; }
10
+ .w-28 { width: 7rem; }
11
+ .w-32 { width: 8rem; }
12
+ .w-36 { width: 9rem; }
13
+ .w-40 { width: 10rem; }
14
+ .w-44 { width: 11rem; }
15
+ .w-48 { width: 12rem; }
16
+ .w-52 { width: 13rem; }
17
+ .w-56 { width: 14rem; }
18
+ .w-60 { width: 15rem; }
19
+ .w-64 { width: 16rem; }
20
+
21
+ /* Min-width utilities */
22
+ .min-w-0 { min-width: 0; }
23
+ .min-w-4 { min-width: 1rem; }
24
+ .min-w-8 { min-width: 2rem; }
25
+ .min-w-12 { min-width: 3rem; }
26
+ .min-w-16 { min-width: 4rem; }
27
+ .min-w-20 { min-width: 5rem; }
28
+ .min-w-24 { min-width: 6rem; }
29
+ .min-w-32 { min-width: 8rem; }
30
+
31
+ /* Max-width utilities */
32
+ .max-w-xs { max-width: 20rem; }
33
+ .max-w-sm { max-width: 24rem; }
34
+ .max-w-md { max-width: 28rem; }
35
+ .max-w-lg { max-width: 32rem; }
36
+ .max-w-xl { max-width: 36rem; }
@@ -0,0 +1,82 @@
1
+ module ChartTableConcern
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ include Pagy::Backend
6
+ include TimeRangeConcern
7
+ include ResponseRangeConcern
8
+ include ZoomRangeConcern
9
+
10
+ before_action :setup_time_and_response_ranges
11
+ before_action :setup_zoom_range_data
12
+ end
13
+
14
+ private
15
+
16
+ def setup_chart_and_table_data
17
+ ransack_params = params[:q] || {}
18
+
19
+ # Setup chart data first using original time range (no sorting from table)
20
+ unless turbo_frame_request?
21
+ setup_chart_formatters
22
+ setup_chart_data(ransack_params)
23
+ end
24
+
25
+ # Setup table data using zoom parameters if present, otherwise use chart parameters
26
+ setup_table_data(ransack_params)
27
+ end
28
+
29
+ def setup_chart_data(ransack_params)
30
+ chart_ransack_params = build_chart_ransack_params(ransack_params)
31
+ chart_ransack_query = chart_model.ransack(chart_ransack_params)
32
+ @chart_data = chart_class.new(
33
+ ransack_query: chart_ransack_query,
34
+ group_by: group_by,
35
+ **chart_options
36
+ ).to_rails_chart
37
+ end
38
+
39
+ def setup_table_data(ransack_params)
40
+ table_ransack_params = build_table_ransack_params(ransack_params)
41
+ @ransack_query = table_model.ransack(table_ransack_params)
42
+ @ransack_query.sorts = default_table_sort if @ransack_query.sorts.empty?
43
+
44
+ table_results = build_table_results
45
+ handle_pagination
46
+ @pagy, @table_data = pagy(table_results, limit: session_pagination_limit)
47
+ end
48
+
49
+ def setup_zoom_range_data
50
+ @zoom_start, @zoom_end, @table_start_time, @table_end_time = setup_zoom_range(@start_time, @end_time)
51
+ end
52
+
53
+ def setup_time_and_response_ranges
54
+ @start_time, @end_time, @selected_time_range, @time_diff_hours = setup_time_range
55
+ @start_duration, @selected_response_range = setup_duration_range
56
+ end
57
+
58
+ def setup_chart_formatters
59
+ @xaxis_formatter = RailsPulse::ChartFormatters.occurred_at_as_time_or_date(@time_diff_hours)
60
+ @tooltip_formatter = RailsPulse::ChartFormatters.tooltip_as_time_or_date_with_marker(@time_diff_hours)
61
+ end
62
+
63
+ def group_by
64
+ @time_diff_hours <= 25 ? :group_by_hour : :group_by_day
65
+ end
66
+
67
+ def handle_pagination
68
+ method = pagination_method
69
+ send(method, params[:limit]) if params[:limit].present?
70
+ end
71
+
72
+ # Abstract methods - must be implemented by including controllers
73
+ def chart_model; raise NotImplementedError; end
74
+ def table_model; raise NotImplementedError; end
75
+ def chart_class; raise NotImplementedError; end
76
+ def chart_options; {}; end
77
+ def build_chart_ransack_params(ransack_params); raise NotImplementedError; end
78
+ def build_table_ransack_params(ransack_params); raise NotImplementedError; end
79
+ def default_table_sort; raise NotImplementedError; end
80
+ def build_table_results; raise NotImplementedError; end
81
+ def pagination_method; :store_pagination_limit; end
82
+ end
@@ -0,0 +1,24 @@
1
+ module ResponseRangeConcern
2
+ extend ActiveSupport::Concern
3
+
4
+ def setup_duration_range(type = :route)
5
+ ransack_params = params[:q] || {}
6
+ thresholds = RailsPulse.configuration.public_send("#{type}_thresholds")
7
+
8
+ if ransack_params[:duration].present?
9
+ selected_range = ransack_params[:duration]
10
+ start_duration =
11
+ case ransack_params[:duration].to_sym
12
+ when :slow then thresholds[:slow]
13
+ when :very_slow then thresholds[:very_slow]
14
+ when :critical then thresholds[:critical]
15
+ else 0
16
+ end
17
+ else
18
+ start_duration = 0
19
+ selected_range = :all
20
+ end
21
+
22
+ [ start_duration, selected_range ]
23
+ end
24
+ end
@@ -0,0 +1,67 @@
1
+ module TimeRangeConcern
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ # Define the constant in the including class - ordered by most common usage
6
+ const_set(:TIME_RANGE_OPTIONS, [
7
+ [ "Last 24 hours", :last_day ],
8
+ [ "Last Week", :last_week ],
9
+ [ "Last Month", :last_month ],
10
+ [ "All Time", :all_time ]
11
+ ].freeze)
12
+ end
13
+
14
+ def setup_time_range
15
+ start_time = 1.day.ago
16
+ end_time = Time.zone.now
17
+ selected_time_range = :last_day
18
+
19
+ ransack_params = params[:q] || {}
20
+
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?
26
+ # Custom time range from chart zoom where there is no association
27
+ start_time = parse_time_param(ransack_params[:occurred_at_gteq])
28
+ end_time = parse_time_param(ransack_params[:occurred_at_lt])
29
+ elsif ransack_params[:occurred_at_range]
30
+ # Predefined time range from dropdown
31
+ selected_time_range = ransack_params[:occurred_at_range]
32
+ start_time =
33
+ case selected_time_range.to_sym
34
+ when :last_day then 1.day.ago
35
+ when :last_week then 1.week.ago
36
+ when :last_month then 1.month.ago
37
+ when :all_time then 100.years.ago
38
+ end
39
+ end
40
+
41
+ time_diff = (end_time.to_i - start_time.to_i) / 3600.0
42
+
43
+ if time_diff <= 25
44
+ start_time = start_time.beginning_of_hour
45
+ end_time = end_time.end_of_hour
46
+ else
47
+ start_time = start_time.beginning_of_day
48
+ end_time = end_time.end_of_day
49
+ end
50
+
51
+ [ start_time, end_time, selected_time_range, time_diff ]
52
+ end
53
+
54
+ private
55
+
56
+ def parse_time_param(param)
57
+ case param
58
+ when Time, DateTime
59
+ param.in_time_zone
60
+ when String
61
+ Time.zone.parse(param)
62
+ else
63
+ # Assume it's an integer timestamp
64
+ Time.zone.at(param.to_i)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,40 @@
1
+ module ZoomRangeConcern
2
+ extend ActiveSupport::Concern
3
+
4
+ def setup_zoom_range(main_start_time, main_end_time)
5
+ # Extract zoom parameters from params (this removes them from params)
6
+ zoom_start = params.delete(:zoom_start_time)
7
+ zoom_end = params.delete(:zoom_end_time)
8
+
9
+ # Normalize zoom times to beginning/end of day or hour like we do for main time range
10
+ if zoom_start && zoom_end
11
+ zoom_start, zoom_end = normalize_zoom_times(zoom_start.to_i, zoom_end.to_i)
12
+ end
13
+
14
+ # Calculate table times - use zoom if present, otherwise fallback to main times
15
+ table_start_time = zoom_start || main_start_time
16
+ table_end_time = zoom_end || main_end_time
17
+
18
+ [ zoom_start, zoom_end, table_start_time, table_end_time ]
19
+ end
20
+
21
+ private
22
+
23
+ def normalize_zoom_times(start_time, end_time)
24
+ time_diff = (end_time - start_time) / 3600.0
25
+
26
+ if time_diff <= 25
27
+ start_time_obj = Time.zone&.at(start_time) || Time.at(start_time)
28
+ end_time_obj = Time.zone&.at(end_time) || Time.at(end_time)
29
+ start_time = start_time_obj&.beginning_of_hour || start_time_obj
30
+ end_time = end_time_obj&.end_of_hour || end_time_obj
31
+ else
32
+ start_time_obj = Time.zone&.at(start_time) || Time.at(start_time)
33
+ end_time_obj = Time.zone&.at(end_time) || Time.at(end_time)
34
+ start_time = start_time_obj&.beginning_of_day || start_time_obj
35
+ end_time = end_time_obj&.end_of_day || end_time_obj
36
+ end
37
+
38
+ [ start_time, end_time ]
39
+ end
40
+ end
@@ -0,0 +1,67 @@
1
+ module RailsPulse
2
+ class ApplicationController < ActionController::Base
3
+ before_action :authenticate_rails_pulse_user!
4
+
5
+ def set_pagination_limit
6
+ session[:pagination_limit] = params[:limit].to_i if params[:limit].present?
7
+ render json: { status: "ok" }
8
+ end
9
+
10
+ private
11
+
12
+ def authenticate_rails_pulse_user!
13
+ return unless RailsPulse.configuration.authentication_enabled
14
+
15
+ # If no authentication method is configured, use fallback HTTP Basic Auth
16
+ if RailsPulse.configuration.authentication_method.nil?
17
+ return fallback_http_basic_auth
18
+ end
19
+
20
+ # Safely execute authentication method in controller context
21
+ case RailsPulse.configuration.authentication_method
22
+ when Proc
23
+ instance_exec(&RailsPulse.configuration.authentication_method)
24
+ when Symbol, String
25
+ method_name = RailsPulse.configuration.authentication_method.to_s
26
+ if respond_to?(method_name, true)
27
+ send(method_name)
28
+ else
29
+ Rails.logger.error "RailsPulse: Authentication method '#{method_name}' not found"
30
+ render plain: "Authentication configuration error", status: :internal_server_error
31
+ end
32
+ else
33
+ Rails.logger.error "RailsPulse: Invalid authentication method type: #{RailsPulse.configuration.authentication_method.class}"
34
+ render plain: "Authentication configuration error", status: :internal_server_error
35
+ end
36
+ rescue StandardError => e
37
+ Rails.logger.warn "RailsPulse authentication failed: #{e.message}"
38
+ redirect_to RailsPulse.configuration.authentication_redirect_path
39
+ end
40
+
41
+ def fallback_http_basic_auth
42
+ authenticate_or_request_with_http_basic("Rails Pulse") do |username, password|
43
+ # Use environment variables for default credentials
44
+ expected_username = ENV.fetch("RAILS_PULSE_USERNAME", "admin")
45
+ expected_password = ENV.fetch("RAILS_PULSE_PASSWORD", nil)
46
+
47
+ if expected_password.nil?
48
+ Rails.logger.error "RailsPulse: No authentication method configured and RAILS_PULSE_PASSWORD not set. Access denied."
49
+ false
50
+ else
51
+ username == expected_username && password == expected_password
52
+ end
53
+ end
54
+ end
55
+
56
+ def session_pagination_limit
57
+ # Keep default small for optimal performance
58
+ session[:pagination_limit] || 10
59
+ end
60
+
61
+ def store_pagination_limit(limit)
62
+ # Validate pagination limit: minimum 5, maximum 50 for performance
63
+ validated_limit = limit.to_i.clamp(5, 50)
64
+ session[:pagination_limit] = validated_limit if limit.present?
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,33 @@
1
+ module RailsPulse
2
+ class AssetsController < ApplicationController
3
+ skip_before_action :verify_authenticity_token, only: [ :show ]
4
+
5
+ def show
6
+ asset_name = params[:asset_name]
7
+ asset_path = Rails.root.join("public", "rails-pulse-assets", asset_name)
8
+
9
+ # Fallback to engine assets if not found in host app
10
+ unless File.exist?(asset_path)
11
+ asset_path = RailsPulse::Engine.root.join("public", "rails-pulse-assets", asset_name)
12
+ end
13
+
14
+ if File.exist?(asset_path)
15
+ content_type = case File.extname(asset_name)
16
+ when ".js" then "application/javascript"
17
+ when ".css" then "text/css"
18
+ when ".map" then "application/json"
19
+ when ".svg" then "image/svg+xml"
20
+ else "application/octet-stream"
21
+ end
22
+
23
+ send_file asset_path,
24
+ type: content_type,
25
+ disposition: "inline",
26
+ cache: true,
27
+ expires: 1.year.from_now
28
+ else
29
+ head :not_found
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,115 @@
1
+ module RailsPulse
2
+ class CachesController < ApplicationController
3
+ def show
4
+ @component_id = params[:id]
5
+ @context = params[:context]
6
+ @cache_key = ComponentCacheKey.build(@component_id, @context)
7
+
8
+ # Preserve component options before refresh
9
+ existing_options = {}
10
+ if params[:refresh]
11
+ existing_cache = Rails.cache.read(@cache_key)
12
+ existing_options = existing_cache[:component_options] if existing_cache&.dig(:component_options)
13
+ Rails.cache.delete(@cache_key)
14
+ end
15
+
16
+ # Check if cache exists with just options (from render_skeleton_with_frame)
17
+ cached_data = Rails.cache.read(@cache_key)
18
+ if cached_data && !cached_data[:component_data]
19
+ # Merge options with full data
20
+ cached_data = {
21
+ component_data: calculate_component_data,
22
+ cached_at: Time.current,
23
+ component_options: cached_data[:component_options] || {}
24
+ }
25
+ Rails.cache.write(@cache_key, cached_data, expires_in: ComponentCacheKey.cache_expires_in)
26
+ elsif !cached_data
27
+ # No cache exists, create new one (use preserved options if refreshing)
28
+ cached_data = {
29
+ component_data: calculate_component_data,
30
+ cached_at: Time.current,
31
+ component_options: existing_options
32
+ }
33
+ Rails.cache.write(@cache_key, cached_data, expires_in: ComponentCacheKey.cache_expires_in)
34
+ end
35
+
36
+ @component_data = cached_data[:component_data]
37
+ @cached_at = cached_data[:cached_at]
38
+ @component_options = cached_data[:component_options] || {}
39
+
40
+ # Update cached_at timestamp in component options if refresh action exists
41
+ if params[:refresh] && @component_options[:actions]
42
+ update_cached_at_in_actions(@component_options[:actions], @cached_at)
43
+ # Update the unified cache with new cached_at timestamp
44
+ cached_data[:component_options] = @component_options
45
+ Rails.cache.write(@cache_key, cached_data, expires_in: ComponentCacheKey.cache_expires_in)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def calculate_component_data
52
+ route = extract_route_from_context
53
+ query = extract_query_from_context
54
+
55
+ case @component_id
56
+ when "average_response_times"
57
+ Routes::Cards::AverageResponseTimes.new(route: route).to_metric_card
58
+ when "percentile_response_times"
59
+ Routes::Cards::PercentileResponseTimes.new(route: route).to_metric_card
60
+ when "request_count_totals"
61
+ Routes::Cards::RequestCountTotals.new(route: route).to_metric_card
62
+ when "error_rate_per_route"
63
+ Routes::Cards::ErrorRatePerRoute.new(route: route).to_metric_card
64
+ when "average_query_times"
65
+ Queries::Cards::AverageQueryTimes.new(query: query).to_metric_card
66
+ when "percentile_query_times"
67
+ Queries::Cards::PercentileQueryTimes.new(query: query).to_metric_card
68
+ when "execution_rate"
69
+ Queries::Cards::ExecutionRate.new(query: query).to_metric_card
70
+ when "dashboard_average_response_time"
71
+ Dashboard::Charts::AverageResponseTime.new.to_chart_data
72
+ when "dashboard_p95_response_time"
73
+ Dashboard::Charts::P95ResponseTime.new.to_chart_data
74
+ when "dashboard_slow_routes"
75
+ Dashboard::Tables::SlowRoutes.new.to_table_data
76
+ when "dashboard_slow_queries"
77
+ Dashboard::Tables::SlowQueries.new.to_table_data
78
+ else
79
+ { title: "Unknown Metric", summary: "N/A" }
80
+ end
81
+ end
82
+
83
+ def extract_route_from_context
84
+ return unless @context
85
+
86
+ # Extract route ID from context like "route_123" or return nil for "routes"/"requests"
87
+ if @context.match(/^route_(\d+)$/)
88
+ route_id = @context.match(/^route_(\d+)$/)[1]
89
+ Route.find(route_id)
90
+ else
91
+ nil
92
+ end
93
+ end
94
+
95
+ def extract_query_from_context
96
+ return unless @context
97
+
98
+ # Extract query ID from context like "query_123" or return nil for other contexts
99
+ if @context.match(/^query_(\d+)$/)
100
+ query_id = @context.match(/^query_(\d+)$/)[1]
101
+ Query.find(query_id)
102
+ else
103
+ nil
104
+ end
105
+ end
106
+
107
+ def update_cached_at_in_actions(actions, cached_at)
108
+ actions.each do |action|
109
+ if action.dig(:data, :rails_pulse__timezone_cached_at_value)
110
+ action[:data][:rails_pulse__timezone_cached_at_value] = cached_at.iso8601
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,57 @@
1
+ # CSP Test Controller for Rails Pulse
2
+ # Tests Content Security Policy compliance with strict policies
3
+
4
+ class RailsPulse::CspTestController < RailsPulse::ApplicationController
5
+ # Strict CSP configuration for testing
6
+ before_action :set_strict_csp
7
+
8
+ def show
9
+ respond_to do |format|
10
+ format.html { render :show }
11
+ format.json { render json: { status: "ok", message: "CSP test endpoint working" } }
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def set_strict_csp
18
+ # Strict Content Security Policy for testing Rails Pulse CSP compliance
19
+ csp_directives = {
20
+ "default-src" => "'self'",
21
+ "script-src" => build_script_src,
22
+ "style-src" => build_style_src,
23
+ "style-src-attr" => "'unsafe-hashes' 'unsafe-inline'", # CSS custom properties
24
+ "img-src" => "'self' data:",
25
+ "font-src" => "'self'",
26
+ "connect-src" => "'self'",
27
+ "frame-src" => "'none'",
28
+ "object-src" => "'none'",
29
+ "base-uri" => "'self'",
30
+ "form-action" => "'self'"
31
+ }
32
+
33
+ response.headers["Content-Security-Policy"] = csp_directives.map { |k, v| "#{k} #{v}" }.join("; ")
34
+ end
35
+
36
+ def build_script_src
37
+ [
38
+ "'self'",
39
+ "'nonce-#{request_nonce}'",
40
+ "'sha256-ieoeWczDHkReVBsRBqaal5AFMlBtNjMzgwKvLqi/tSU='" # Known safe inline script
41
+ ].join(" ")
42
+ end
43
+
44
+ def build_style_src
45
+ [
46
+ "'self'",
47
+ "'nonce-#{request_nonce}'",
48
+ "'sha256-WAyOw4V+FqDc35lQPyRADLBWbuNK8ahvYEaQIYF1+Ps='" # Icon controller styles
49
+ ].join(" ")
50
+ end
51
+
52
+ def request_nonce
53
+ @request_nonce ||= SecureRandom.base64(32)
54
+ end
55
+
56
+ helper_method :request_nonce
57
+ end
@@ -0,0 +1,6 @@
1
+ module RailsPulse
2
+ class DashboardController < ApplicationController
3
+ def index
4
+ end
5
+ end
6
+ end