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,73 @@
1
+ module RailsPulse
2
+ module CachedComponentHelper
3
+ def cached_component(options)
4
+ # cache_key = ComponentCacheKey.build(options[:id], options[:context])
5
+
6
+ # Add refresh action for panels if requested
7
+ if options[:refresh_action] && options[:component] == "panel"
8
+ options[:actions] ||= []
9
+ options[:actions] << refresh_action_params(options[:id], options[:context], options[:content_partial])
10
+ end
11
+
12
+ # if Rails.cache.exist?(cache_key)
13
+ if false
14
+ render_cached_content(options)
15
+ else
16
+ render_skeleton_with_frame(options)
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def render_cached_content(options)
23
+ cache_key = ComponentCacheKey.build(options[:id], options[:context])
24
+ cached_data = Rails.cache.read(cache_key)
25
+ @component_data = cached_data[:component_data]
26
+ @cached_at = cached_data[:cached_at]
27
+ component_options = cached_data[:component_options] || {}
28
+
29
+ # Wrap the cached content in a Turbo Frame so it can be refreshed using a refresh link in the component
30
+ turbo_frame_tag "#{options[:id]}_#{options[:component]}", class: options[:class] do
31
+ render "rails_pulse/components/#{options[:component]}", component_options
32
+ end
33
+ end
34
+
35
+ def render_skeleton_with_frame(options)
36
+ # Store component options temporarily so CachesController can access them
37
+ cache_key = ComponentCacheKey.build(options[:id], options[:context])
38
+ Rails.cache.write(cache_key, component_options: options, expires_in: 5.minutes)
39
+ path_options = options.slice :id, :context
40
+
41
+ turbo_frame_tag "#{options[:id]}_#{options[:component]}",
42
+ src: rails_pulse.cache_path(**path_options),
43
+ loading: "eager",
44
+ class: options[:class] do
45
+ render "rails_pulse/skeletons/#{options[:component]}", options
46
+ end
47
+ end
48
+
49
+ def refresh_action_params(id, context, content_partial)
50
+ refresh_params = {
51
+ id: id,
52
+ component_type: "panel",
53
+ refresh: true
54
+ }
55
+
56
+ # Include content_partial in refresh URL if available
57
+ refresh_params[:content_partial] = content_partial if content_partial
58
+
59
+ {
60
+ url: rails_pulse.cache_path(refresh_params),
61
+ icon: "refresh-cw",
62
+ title: "Refresh data",
63
+ data: {
64
+ controller: "rails-pulse--timezone",
65
+ rails_pulse__timezone_target_frame_value: "#{id}_panel",
66
+ rails_pulse__timezone_cached_at_value: Time.current.iso8601,
67
+ turbo_frame: "#{id}_panel",
68
+ turbo_prefetch: "false"
69
+ }
70
+ }
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,43 @@
1
+ module RailsPulse
2
+ module ChartFormatters
3
+ def self.occurred_at_as_time_or_date(time_diff_hours)
4
+ if time_diff_hours <= 25
5
+ <<~JS
6
+ function(value) {
7
+ const date = new Date(value * 1000);
8
+ return date.getHours().toString().padStart(2, '0') + ':00';
9
+ }
10
+ JS
11
+ else
12
+ <<~JS
13
+ function(value) {
14
+ const date = new Date(value * 1000);
15
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
16
+ }
17
+ JS
18
+ end
19
+ end
20
+
21
+ def self.tooltip_as_time_or_date_with_marker(time_diff_hours)
22
+ if time_diff_hours <= 25
23
+ <<~JS
24
+ function(params) {
25
+ const data = params[0];
26
+ const date = new Date(data.axisValue * 1000);
27
+ const dateString = date.getHours().toString().padStart(2, '0') + ':00';
28
+ return `${dateString} <br /> ${data.marker} ${parseInt(data.data.value)} ms`;
29
+ }
30
+ JS
31
+ else
32
+ <<~JS
33
+ function(params) {
34
+ const data = params[0];
35
+ const date = new Date(data.axisValue * 1000);
36
+ const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
37
+ return `${dateString} <br /> ${data.marker} ${parseInt(data.data.value)} ms`;
38
+ }
39
+ JS
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,140 @@
1
+ module RailsPulse
2
+ module ChartHelper
3
+ # Base chart options shared across all chart types
4
+ def base_chart_options(units: nil, zoom: false)
5
+ {
6
+ tooltip: {
7
+ trigger: "axis",
8
+ axisPointer: { type: "shadow" }
9
+ },
10
+ toolbox: {
11
+ feature: { saveAsImage: { show: false } }
12
+ },
13
+ xAxis: {
14
+ axisLine: { show: false },
15
+ axisTick: { show: false }
16
+ },
17
+ yAxis: {
18
+ splitArea: { show: false },
19
+ axisLabel: {
20
+ formatter: "{value} #{units}"
21
+ }
22
+ },
23
+ grid: {
24
+ left: "0",
25
+ right: "2%",
26
+ bottom: (zoom ? "60" : "0"),
27
+ top: "10%",
28
+ containLabel: true
29
+ },
30
+ animation: false
31
+ }
32
+ end
33
+
34
+ def bar_chart_options(units: nil, zoom: false, chart_start: 0, chart_end: 100, xaxis_formatter: nil, tooltip_formatter: nil, zoom_start: nil, zoom_end: nil, chart_data: nil)
35
+ options = base_chart_options(units: units, zoom: zoom).deep_merge({
36
+ series: {
37
+ itemStyle: { borderRadius: [ 5, 5, 5, 5 ] }
38
+ }
39
+ })
40
+
41
+ apply_tooltip_formatter(options, tooltip_formatter)
42
+ apply_xaxis_formatter(options, xaxis_formatter)
43
+ apply_zoom_configuration(options, zoom, zoom_start, zoom_end, chart_data)
44
+
45
+ options
46
+ end
47
+
48
+ def line_chart_options(units: nil, zoom: false, chart_start: 0, chart_end: 100, xaxis_formatter: nil, tooltip_formatter: nil, zoom_start: nil, zoom_end: nil, chart_data: nil)
49
+ options = base_chart_options(units: units, zoom: zoom).deep_merge({
50
+ series: {
51
+ smooth: true,
52
+ lineStyle: { width: 3 },
53
+ symbol: "circle",
54
+ symbolSize: 8
55
+ }
56
+ })
57
+
58
+ apply_tooltip_formatter(options, tooltip_formatter)
59
+ apply_xaxis_formatter(options, xaxis_formatter)
60
+ apply_zoom_configuration(options, zoom, zoom_start, zoom_end, chart_data)
61
+
62
+ options
63
+ end
64
+
65
+ def sparkline_chart_options
66
+ base_chart_options.deep_merge({
67
+ series: {
68
+ type: "line",
69
+ smooth: true,
70
+ lineStyle: { width: 2 },
71
+ symbol: "none"
72
+ },
73
+ yAxis: { show: false },
74
+ xAxis: { splitLine: { show: false } },
75
+ grid: { show: false }
76
+ })
77
+ end
78
+
79
+ def area_chart_options
80
+ base_chart_options.deep_merge({
81
+ series: {
82
+ smooth: true,
83
+ lineStyle: { width: 3 },
84
+ symbol: "roundRect",
85
+ symbolSize: 8
86
+ }
87
+ })
88
+ end
89
+
90
+ private
91
+
92
+ def apply_tooltip_formatter(options, tooltip_formatter)
93
+ return unless tooltip_formatter.present?
94
+
95
+ options[:tooltip][:formatter] = RailsCharts.js(tooltip_formatter)
96
+ end
97
+
98
+ def apply_xaxis_formatter(options, xaxis_formatter)
99
+ return unless xaxis_formatter.present?
100
+
101
+ options[:xAxis][:axisLabel] ||= { formatter: RailsCharts.js(xaxis_formatter) }
102
+ end
103
+
104
+ def apply_zoom_configuration(options, zoom, zoom_start, zoom_end, chart_data)
105
+ return unless zoom
106
+
107
+ zoom_config = {
108
+ type: "slider",
109
+ height: 20,
110
+ bottom: 10,
111
+ showDetail: false
112
+ }
113
+
114
+ # Initialize zoom range if zoom parameters are provided
115
+ if zoom_start.present? && zoom_end.present? && chart_data.present?
116
+ # Find closest matching timestamps in the actual chart data
117
+ # Chart data is a hash like: { 1234567890 => { value: 123.45 } }
118
+ chart_timestamps = chart_data.keys
119
+
120
+ 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 }
123
+
124
+ # Find the array indices of these timestamps
125
+ start_index = chart_timestamps.index(closest_start)
126
+ end_index = chart_timestamps.index(closest_end)
127
+
128
+ # Use array indices for dataZoom instead of timestamp values
129
+ zoom_config[:startValue] = start_index
130
+ zoom_config[:endValue] = end_index
131
+ end
132
+ end
133
+
134
+ options[:dataZoom] = [
135
+ zoom_config,
136
+ { type: "inside" }
137
+ ]
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,29 @@
1
+ module RailsPulse
2
+ module FormattingHelper
3
+ def human_readable_occurred_at(occurred_at)
4
+ return "" unless occurred_at.present?
5
+ time = occurred_at.is_a?(String) ? Time.parse(occurred_at) : occurred_at
6
+ time.strftime("%b %d, %Y %l:%M %p")
7
+ end
8
+
9
+ def time_ago_in_words(time)
10
+ return "Unknown" if time.blank?
11
+
12
+ # Convert to Time object if it's a string
13
+ time = Time.parse(time.to_s) if time.is_a?(String)
14
+
15
+ seconds_ago = Time.current - time
16
+
17
+ case seconds_ago
18
+ when 0..59
19
+ "#{seconds_ago.to_i}s ago"
20
+ when 60..3599
21
+ "#{(seconds_ago / 60).to_i}m ago"
22
+ when 3600..86399
23
+ "#{(seconds_ago / 3600).to_i}h ago"
24
+ else
25
+ "#{(seconds_ago / 86400).to_i}d ago"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,279 @@
1
+ module RailsPulse
2
+ module StatusHelper
3
+ def route_status_indicator(status_value)
4
+ case status_value.to_i
5
+ when 0
6
+ # Healthy routes show no icon to reduce visual clutter
7
+ ""
8
+ when 1
9
+ content_tag(
10
+ :span,
11
+ lucide_icon("alert-triangle", width: "16", height: "16", class: "text-yellow-600"),
12
+ title: "Warning - Response time > #{RailsPulse.configuration.route_thresholds[:slow]} ms"
13
+ )
14
+ when 2
15
+ content_tag(
16
+ :span,
17
+ lucide_icon("alert-circle", width: "16", height: "16", class: "text-orange-600"),
18
+ title: "Slow - Response time > #{RailsPulse.configuration.route_thresholds[:very_slow]} ms"
19
+ )
20
+ when 3
21
+ content_tag(
22
+ :span,
23
+ lucide_icon("x-circle", width: "16", height: "16", class: "text-red-600"),
24
+ title: "Critical - Response time > #{RailsPulse.configuration.route_thresholds[:critical]} ms"
25
+ )
26
+ else
27
+ content_tag(
28
+ :span,
29
+ lucide_icon("help-circle", width: "16", height: "16", class: "text-gray-400"),
30
+ title: "Unknown status"
31
+ )
32
+ end
33
+ end
34
+
35
+ def request_status_indicator(duration)
36
+ thresholds = RailsPulse.configuration.request_thresholds
37
+ status_value = case duration.to_i
38
+ when 0...thresholds[:slow]
39
+ 0 # Healthy
40
+ when thresholds[:slow]...thresholds[:very_slow]
41
+ 1 # Warning
42
+ when thresholds[:very_slow]...thresholds[:critical]
43
+ 2 # Slow
44
+ else
45
+ 3 # Critical
46
+ end
47
+
48
+ case status_value
49
+ when 0
50
+ # Healthy requests show no icon to reduce visual clutter
51
+ ""
52
+ when 1
53
+ content_tag(
54
+ :span,
55
+ lucide_icon("alert-triangle", width: "16", height: "16", class: "text-yellow-600"),
56
+ title: "Warning - Response time > #{thresholds[:slow]} ms"
57
+ )
58
+ when 2
59
+ content_tag(
60
+ :span,
61
+ lucide_icon("alert-circle", width: "16", height: "16", class: "text-orange-600"),
62
+ title: "Slow - Response time > #{thresholds[:very_slow]} ms"
63
+ )
64
+ when 3
65
+ content_tag(
66
+ :span,
67
+ lucide_icon("x-circle", width: "16", height: "16", class: "text-red-600"),
68
+ title: "Critical - Response time > #{thresholds[:critical]} ms"
69
+ )
70
+ else
71
+ content_tag(
72
+ :span,
73
+ lucide_icon("help-circle", width: "16", height: "16", class: "text-gray-400"),
74
+ title: "Unknown status"
75
+ )
76
+ end
77
+ end
78
+
79
+ def query_status_indicator(avg_duration)
80
+ thresholds = RailsPulse.configuration.query_thresholds
81
+ status_value = case avg_duration.to_f
82
+ when 0...thresholds[:slow]
83
+ 0 # Healthy
84
+ when thresholds[:slow]...thresholds[:very_slow]
85
+ 1 # Warning
86
+ when thresholds[:very_slow]...thresholds[:critical]
87
+ 2 # Slow
88
+ else
89
+ 3 # Critical
90
+ end
91
+
92
+ case status_value
93
+ when 0
94
+ # Healthy queries show no icon to reduce visual clutter
95
+ ""
96
+ when 1
97
+ content_tag(
98
+ :span,
99
+ lucide_icon("alert-triangle", width: "16", height: "16", class: "text-yellow-600"),
100
+ title: "Warning - Query time > #{thresholds[:slow]} ms"
101
+ )
102
+ when 2
103
+ content_tag(
104
+ :span,
105
+ lucide_icon("alert-circle", width: "16", height: "16", class: "text-orange-600"),
106
+ title: "Slow - Query time > #{thresholds[:very_slow]} ms"
107
+ )
108
+ when 3
109
+ content_tag(
110
+ :span,
111
+ lucide_icon("x-circle", width: "16", height: "16", class: "text-red-600"),
112
+ title: "Critical - Query time > #{thresholds[:critical]} ms"
113
+ )
114
+ else
115
+ content_tag(
116
+ :span,
117
+ lucide_icon("help-circle", width: "16", height: "16", class: "text-gray-400"),
118
+ title: "Unknown status"
119
+ )
120
+ end
121
+ end
122
+
123
+ def operation_status_indicator(operation)
124
+ # Define operation-specific thresholds
125
+ thresholds = case operation.operation_type
126
+ when "sql"
127
+ { slow: 50, very_slow: 100, critical: 500 }
128
+ when "template", "partial", "layout", "collection"
129
+ { slow: 50, very_slow: 150, critical: 300 }
130
+ when "controller"
131
+ { slow: 200, very_slow: 500, critical: 1000 }
132
+ when "cache_read", "cache_write"
133
+ { slow: 10, very_slow: 50, critical: 100 }
134
+ when "http"
135
+ { slow: 500, very_slow: 1000, critical: 3000 }
136
+ when "job"
137
+ { slow: 1000, very_slow: 5000, critical: 10000 }
138
+ when "mailer"
139
+ { slow: 500, very_slow: 2000, critical: 5000 }
140
+ when "storage"
141
+ { slow: 500, very_slow: 1000, critical: 3000 }
142
+ else
143
+ { slow: 100, very_slow: 300, critical: 1000 }
144
+ end
145
+
146
+ duration = operation.duration.to_f
147
+ status_value = case duration
148
+ when 0...thresholds[:slow]
149
+ 0 # Healthy
150
+ when thresholds[:slow]...thresholds[:very_slow]
151
+ 1 # Warning
152
+ when thresholds[:very_slow]...thresholds[:critical]
153
+ 2 # Slow
154
+ else
155
+ 3 # Critical
156
+ end
157
+
158
+ case status_value
159
+ when 0
160
+ # Healthy operations show no icon to reduce visual clutter
161
+ ""
162
+ when 1
163
+ content_tag(
164
+ :span,
165
+ lucide_icon("alert-triangle", width: "16", height: "16", class: "text-yellow-600"),
166
+ title: "Warning - Operation time > #{thresholds[:slow]} ms"
167
+ )
168
+ when 2
169
+ content_tag(
170
+ :span,
171
+ lucide_icon("alert-circle", width: "16", height: "16", class: "text-orange-600"),
172
+ title: "Slow - Operation time > #{thresholds[:very_slow]} ms"
173
+ )
174
+ when 3
175
+ content_tag(
176
+ :span,
177
+ lucide_icon("x-circle", width: "16", height: "16", class: "text-red-600"),
178
+ title: "Critical - Operation time > #{thresholds[:critical]} ms"
179
+ )
180
+ else
181
+ content_tag(
182
+ :span,
183
+ lucide_icon("help-circle", width: "16", height: "16", class: "text-gray-400"),
184
+ title: "Unknown status"
185
+ )
186
+ end
187
+ end
188
+
189
+ def operations_performance_breakdown(operations)
190
+ return { database: 0, view: 0, application: 0, other: 0 } if operations.empty?
191
+
192
+ total_duration = operations.sum(&:duration).to_f
193
+ return { database: 0, view: 0, application: 0, other: 0 } if total_duration.zero?
194
+
195
+ breakdown = operations.group_by { |op| categorize_operation(op.operation_type) }
196
+ .transform_values { |ops| ops.sum(&:duration) }
197
+
198
+ {
199
+ database: ((breakdown[:database] || 0) / total_duration * 100).round(1),
200
+ view: ((breakdown[:view] || 0) / total_duration * 100).round(1),
201
+ application: ((breakdown[:application] || 0) / total_duration * 100).round(1),
202
+ other: ((breakdown[:other] || 0) / total_duration * 100).round(1)
203
+ }
204
+ end
205
+
206
+ def categorize_operation(operation_type)
207
+ case operation_type
208
+ when "sql"
209
+ :database
210
+ when "template", "partial", "layout", "collection"
211
+ :view
212
+ when "controller"
213
+ :application
214
+ else
215
+ :other
216
+ end
217
+ end
218
+
219
+ def operation_category_label(operation_type)
220
+ case categorize_operation(operation_type)
221
+ when :database
222
+ "Database"
223
+ when :view
224
+ "View Rendering"
225
+ when :application
226
+ "Application Logic"
227
+ else
228
+ "Other Operations"
229
+ end
230
+ end
231
+
232
+ def performance_badge_class(percentile)
233
+ case percentile
234
+ when 0..50
235
+ "badge--positive"
236
+ when 51..75
237
+ "badge--warning"
238
+ when 76..90
239
+ "badge--negative"
240
+ else
241
+ "badge--critical"
242
+ end
243
+ end
244
+
245
+ def rescue_template_missing
246
+ yield
247
+ true
248
+ rescue ActionView::MissingTemplate
249
+ false
250
+ end
251
+
252
+ def truncate_sql(sql, length: 100)
253
+ return sql if sql.length <= length
254
+ sql.truncate(length)
255
+ end
256
+
257
+ def event_color(operation_type)
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"
263
+ end
264
+ end
265
+
266
+ def duration_options(type = :route)
267
+ thresholds = RailsPulse.configuration.public_send("#{type}_thresholds")
268
+
269
+ first_label = "All #{type.to_s.humanize.pluralize}"
270
+
271
+ [
272
+ [ first_label, :all ],
273
+ [ "Slow (≥ #{thresholds[:slow]}ms)", :slow ],
274
+ [ "Very Slow (≥ #{thresholds[:very_slow]}ms)", :very_slow ],
275
+ [ "Critical (≥ #{thresholds[:critical]}ms)", :critical ]
276
+ ]
277
+ end
278
+ end
279
+ end
@@ -0,0 +1,54 @@
1
+ module RailsPulse
2
+ module TableHelper
3
+ def render_cell_content(row_data, column)
4
+ value = row_data[column[:field]]
5
+
6
+ # Handle links
7
+ if column[:link_to] && row_data[column[:link_to]]
8
+ # Direct link provided
9
+ link_to value, row_data[column[:link_to]], data: { turbo_frame: "_top" }
10
+ elsif column[:link_field] && row_data[column[:link_field]]
11
+ # Generate link based on field type and ID
12
+ case column[:link_field]
13
+ when :query_id
14
+ link_to value, query_path(row_data[column[:link_field]]), data: { turbo_frame: "_top" }
15
+ when :route_id
16
+ link_to value, route_path(row_data[column[:link_field]]), data: { turbo_frame: "_top" }
17
+ else
18
+ value
19
+ end
20
+ elsif column[:format] == :percentage && value.is_a?(Numeric)
21
+ "#{value > 0 ? '+' : ''}#{value}%"
22
+ elsif value.is_a?(Numeric) && column[:field].to_s.include?("time")
23
+ "#{value.round(0)} ms"
24
+ else
25
+ value
26
+ end
27
+ end
28
+
29
+ def cell_highlight_class(row_data, column)
30
+ return "" unless column[:highlight]
31
+
32
+ case column[:highlight]
33
+ when :trend
34
+ trend = row_data[:trend]
35
+ case trend
36
+ when "worse" then "highlight-red"
37
+ when "better" then "highlight-green"
38
+ else ""
39
+ end
40
+ when :percentage_change
41
+ change = row_data[:percentage_change]
42
+ if change && change > 5
43
+ "highlight-red"
44
+ elsif change && change < -5
45
+ "highlight-green"
46
+ else
47
+ ""
48
+ end
49
+ else
50
+ ""
51
+ end
52
+ end
53
+ end
54
+ end