rails_pulse 0.1.3 → 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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +56 -16
  3. data/Rakefile +169 -86
  4. data/app/controllers/rails_pulse/queries_controller.rb +14 -20
  5. data/app/controllers/rails_pulse/requests_controller.rb +43 -30
  6. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +1 -1
  7. data/app/helpers/rails_pulse/chart_helper.rb +1 -1
  8. data/app/helpers/rails_pulse/formatting_helper.rb +21 -2
  9. data/app/javascript/rails_pulse/controllers/index_controller.js +11 -3
  10. data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +1 -1
  11. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +1 -1
  12. data/app/models/rails_pulse/queries/cards/average_query_times.rb +1 -1
  13. data/app/models/rails_pulse/queries/cards/execution_rate.rb +56 -17
  14. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +1 -1
  15. data/app/models/rails_pulse/queries/charts/average_query_times.rb +3 -7
  16. data/app/models/rails_pulse/request.rb +1 -1
  17. data/app/models/rails_pulse/requests/charts/average_response_times.rb +2 -2
  18. data/app/models/rails_pulse/requests/tables/index.rb +77 -0
  19. data/app/models/rails_pulse/routes/cards/average_response_times.rb +1 -1
  20. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +1 -1
  21. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +1 -1
  22. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +16 -5
  23. data/app/models/rails_pulse/routes/tables/index.rb +4 -2
  24. data/app/models/rails_pulse/summary.rb +7 -7
  25. data/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb +11 -3
  26. data/app/views/rails_pulse/components/_metric_card.html.erb +2 -2
  27. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
  28. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +1 -1
  29. data/app/views/rails_pulse/dashboard/index.html.erb +1 -1
  30. data/app/views/rails_pulse/queries/_analysis_results.html.erb +53 -23
  31. data/app/views/rails_pulse/queries/_show_table.html.erb +33 -5
  32. data/app/views/rails_pulse/queries/_table.html.erb +3 -7
  33. data/app/views/rails_pulse/requests/_table.html.erb +30 -19
  34. data/app/views/rails_pulse/requests/index.html.erb +8 -0
  35. data/app/views/rails_pulse/requests/show.html.erb +0 -2
  36. data/app/views/rails_pulse/routes/_requests_table.html.erb +39 -0
  37. data/app/views/rails_pulse/routes/_table.html.erb +3 -9
  38. data/app/views/rails_pulse/routes/show.html.erb +3 -5
  39. data/config/initializers/rails_charts_csp_patch.rb +32 -40
  40. data/db/migrate/20250930105043_install_rails_pulse_tables.rb +23 -0
  41. data/db/rails_pulse_schema.rb +1 -1
  42. data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +25 -9
  43. data/lib/generators/rails_pulse/install_generator.rb +9 -5
  44. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +72 -2
  45. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +3 -2
  46. data/lib/generators/rails_pulse/upgrade_generator.rb +2 -1
  47. data/lib/rails_pulse/engine.rb +21 -0
  48. data/lib/rails_pulse/version.rb +1 -1
  49. data/public/rails-pulse-assets/rails-pulse.js +1 -1
  50. data/public/rails-pulse-assets/rails-pulse.js.map +2 -2
  51. metadata +5 -4
  52. data/db/migrate/20241222000001_create_rails_pulse_summaries.rb +0 -54
  53. data/db/migrate/20250916031656_add_analysis_to_rails_pulse_queries.rb +0 -13
@@ -192,6 +192,8 @@ export default class extends Controller {
192
192
  updatePaginationLimit() {
193
193
  // Update or set the limit param in the browser so if the user refreshes the page,
194
194
  // the limit will be preserved.
195
+ if (!this.hasPaginationLimitTarget) return;
196
+
195
197
  const url = new URL(window.location.href);
196
198
  const currentParams = new URLSearchParams(url.search);
197
199
  const limit = this.paginationLimitTarget.value;
@@ -244,7 +246,9 @@ export default class extends Controller {
244
246
  currentParams.set('zoom_end_time', endTimestamp);
245
247
 
246
248
  // Set the limit param based on the value in the pagination selector
247
- currentParams.set('limit', this.paginationLimitTarget.value);
249
+ if (this.hasPaginationLimitTarget) {
250
+ currentParams.set('limit', this.paginationLimitTarget.value);
251
+ }
248
252
 
249
253
  // Update the URL's search parameters
250
254
  url.search = currentParams.toString();
@@ -443,7 +447,9 @@ export default class extends Controller {
443
447
  currentParams.set('selected_column_time', selectedTimestamp);
444
448
 
445
449
  // Preserve pagination limit
446
- currentParams.set('limit', this.paginationLimitTarget.value);
450
+ if (this.hasPaginationLimitTarget) {
451
+ currentParams.set('limit', this.paginationLimitTarget.value);
452
+ }
447
453
 
448
454
  url.search = currentParams.toString();
449
455
 
@@ -463,7 +469,9 @@ export default class extends Controller {
463
469
  currentParams.delete('selected_column_time');
464
470
 
465
471
  // Preserve pagination limit
466
- currentParams.set('limit', this.paginationLimitTarget.value);
472
+ if (this.hasPaginationLimitTarget) {
473
+ currentParams.set('limit', this.paginationLimitTarget.value);
474
+ }
467
475
 
468
476
  url.search = currentParams.toString();
469
477
 
@@ -32,7 +32,7 @@ module RailsPulse
32
32
  {
33
33
  query_text: truncate_query(record.normalized_sql),
34
34
  query_id: record.query_id,
35
- query_link: "/rails_pulse/queries/#{record.query_id}",
35
+ query_link: RailsPulse::Engine.routes.url_helpers.query_path(record.query_id),
36
36
  average_time: record.avg_duration.to_f.round(0),
37
37
  request_count: record.request_count,
38
38
  last_request: time_ago_in_words(record.last_seen)
@@ -33,7 +33,7 @@ module RailsPulse
33
33
  {
34
34
  route_path: record.path,
35
35
  route_id: record.route_id,
36
- route_link: "/rails_pulse/routes/#{record.route_id}",
36
+ route_link: RailsPulse::Engine.routes.url_helpers.route_path(record.route_id),
37
37
  average_time: record.avg_duration.to_f.round(0),
38
38
  request_count: record.request_count,
39
39
  last_request: time_ago_in_words(record.last_seen)
@@ -64,7 +64,7 @@ module RailsPulse
64
64
  context: "queries",
65
65
  title: "Average Query Time",
66
66
  summary: "#{average_query_time} ms",
67
- line_chart_data: sparkline_data,
67
+ chart_data: sparkline_data,
68
68
  trend_icon: trend_icon,
69
69
  trend_amount: trend_amount,
70
70
  trend_text: "Compared to last week"
@@ -10,10 +10,20 @@ module RailsPulse
10
10
  last_7_days = 7.days.ago.beginning_of_day
11
11
  previous_7_days = 14.days.ago.beginning_of_day
12
12
 
13
+ # Get the most common period type for this query, or fall back to "day"
14
+ period_type = if @query
15
+ RailsPulse::Summary.where(
16
+ summarizable_type: "RailsPulse::Query",
17
+ summarizable_id: @query.id
18
+ ).group(:period_type).count.max_by(&:last)&.first || "day"
19
+ else
20
+ "day"
21
+ end
22
+
13
23
  # Single query to get all count metrics with conditional aggregation
14
24
  base_query = RailsPulse::Summary.where(
15
25
  summarizable_type: "RailsPulse::Query",
16
- period_type: "day",
26
+ period_type: period_type,
17
27
  period_start: 2.weeks.ago.beginning_of_day..Time.current
18
28
  )
19
29
  base_query = base_query.where(summarizable_id: @query.id) if @query
@@ -33,31 +43,60 @@ module RailsPulse
33
43
  trend_icon = percentage < 0.1 ? "move-right" : current_period_count < previous_period_count ? "trending-down" : "trending-up"
34
44
  trend_amount = previous_period_count.zero? ? "0%" : "#{percentage}%"
35
45
 
36
- # Sparkline data by day with zero-filled days over the last 14 days
37
- grouped_daily = base_query
38
- .group_by_day(:period_start, time_zone: "UTC")
39
- .sum(:count)
46
+ # Sparkline data with zero-filled periods over the last 14 days
47
+ if period_type == "day"
48
+ grouped_data = base_query
49
+ .group_by_day(:period_start, time_zone: "UTC")
50
+ .sum(:count)
51
+
52
+ start_period = 2.weeks.ago.beginning_of_day.to_date
53
+ end_period = Time.current.to_date
40
54
 
41
- start_day = 2.weeks.ago.beginning_of_day.to_date
42
- end_day = Time.current.to_date
55
+ sparkline_data = {}
56
+ (start_period..end_period).each do |day|
57
+ total = grouped_data[day] || 0
58
+ label = day.strftime("%b %-d")
59
+ sparkline_data[label] = { value: total }
60
+ end
61
+ else
62
+ # For hourly data, group by day for sparkline display
63
+ grouped_data = base_query
64
+ .group("DATE(period_start)")
65
+ .sum(:count)
43
66
 
44
- sparkline_data = {}
45
- (start_day..end_day).each do |day|
46
- total = grouped_daily[day] || 0
47
- label = day.strftime("%b %-d")
48
- sparkline_data[label] = { value: total }
67
+ start_period = 2.weeks.ago.beginning_of_day.to_date
68
+ end_period = Time.current.to_date
69
+
70
+ sparkline_data = {}
71
+ (start_period..end_period).each do |day|
72
+ date_key = day.strftime("%Y-%m-%d")
73
+ total = grouped_data[date_key] || 0
74
+ label = day.strftime("%b %-d")
75
+ sparkline_data[label] = { value: total }
76
+ end
49
77
  end
50
78
 
51
- # Calculate average executions per minute over 2-week period
52
- total_minutes = 2.weeks / 1.minute
53
- average_executions_per_minute = total_execution_count / total_minutes
79
+ # Calculate appropriate rate display based on frequency
80
+ total_minutes = 2.weeks / 1.minute.to_f
81
+ executions_per_minute = total_execution_count.to_f / total_minutes
82
+
83
+ # Choose appropriate time unit for display
84
+ if executions_per_minute >= 1
85
+ summary = "#{executions_per_minute.round(2)} / min"
86
+ elsif executions_per_minute * 60 >= 1
87
+ executions_per_hour = executions_per_minute * 60
88
+ summary = "#{executions_per_hour.round(2)} / hour"
89
+ else
90
+ executions_per_day = executions_per_minute * 60 * 24
91
+ summary = "#{executions_per_day.round(2)} / day"
92
+ end
54
93
 
55
94
  {
56
95
  id: "execution_rate",
57
96
  context: "queries",
58
97
  title: "Execution Rate",
59
- summary: "#{average_executions_per_minute.round(2)} / min",
60
- line_chart_data: sparkline_data,
98
+ summary: summary,
99
+ chart_data: sparkline_data,
61
100
  trend_icon: trend_icon,
62
101
  trend_amount: trend_amount,
63
102
  trend_text: "Compared to last week"
@@ -53,7 +53,7 @@ module RailsPulse
53
53
  context: "queries",
54
54
  title: "95th Percentile Query Time",
55
55
  summary: "#{p95_query_time} ms",
56
- line_chart_data: sparkline_data,
56
+ chart_data: sparkline_data,
57
57
  trend_icon: trend_icon,
58
58
  trend_amount: trend_amount,
59
59
  trend_text: "Compared to last week"
@@ -12,13 +12,9 @@ module RailsPulse
12
12
  end
13
13
 
14
14
  def to_rails_chart
15
- summaries = @ransack_query.result(distinct: false).where(
16
- summarizable_type: "RailsPulse::Query",
17
- period_type: @period_type
18
- )
19
-
20
- summaries = summaries.where(summarizable_id: @query.id) if @query
21
- summaries = summaries
15
+ # The ransack query already contains the correct filters, just add period_type
16
+ summaries = @ransack_query.result(distinct: false)
17
+ .where(period_type: @period_type)
22
18
  .group(:period_start)
23
19
  .having("AVG(avg_duration) > ?", @start_duration || 0)
24
20
  .average(:avg_duration)
@@ -52,7 +52,7 @@ module RailsPulse
52
52
  end
53
53
 
54
54
  def to_s
55
- occurred_at.strftime("%b %d, %Y %l:%M %p")
55
+ occurred_at.getlocal.strftime("%b %d, %Y %l:%M %p")
56
56
  end
57
57
 
58
58
  private
@@ -13,11 +13,11 @@ module RailsPulse
13
13
 
14
14
  def to_rails_chart
15
15
  summaries = @ransack_query.result(distinct: false).where(
16
- summarizable_type: "RailsPulse::Route",
16
+ summarizable_type: "RailsPulse::Request",
17
+ summarizable_id: 0, # Overall request summaries
17
18
  period_type: @period_type
18
19
  )
19
20
 
20
- summaries = summaries.where(summarizable_id: @route.id) if @route
21
21
  summaries = summaries
22
22
  .group(:period_start)
23
23
  .having("AVG(avg_duration) > ?", @start_duration || 0)
@@ -0,0 +1,77 @@
1
+ module RailsPulse
2
+ module Requests
3
+ module Tables
4
+ class Index
5
+ def initialize(ransack_query:, period_type: nil, start_time:, params:)
6
+ @ransack_query = ransack_query
7
+ @period_type = period_type
8
+ @start_time = start_time
9
+ @params = params
10
+ end
11
+
12
+ def to_table
13
+ # Check if we have explicit ransack sorts
14
+ has_sorts = @ransack_query.sorts.any?
15
+
16
+ base_query = @ransack_query.result(distinct: false)
17
+ .where(
18
+ summarizable_type: "RailsPulse::Request",
19
+ summarizable_id: 0, # Overall request summaries
20
+ period_type: @period_type
21
+ )
22
+
23
+ # Apply grouping and aggregation for time periods
24
+ grouped_query = base_query
25
+ .group(
26
+ "rails_pulse_summaries.period_start",
27
+ "rails_pulse_summaries.period_end",
28
+ "rails_pulse_summaries.period_type"
29
+ )
30
+ .select(
31
+ "rails_pulse_summaries.period_start",
32
+ "rails_pulse_summaries.period_end",
33
+ "rails_pulse_summaries.period_type",
34
+ "AVG(rails_pulse_summaries.avg_duration) as avg_duration",
35
+ "MAX(rails_pulse_summaries.max_duration) as max_duration",
36
+ "MIN(rails_pulse_summaries.min_duration) as min_duration",
37
+ "SUM(rails_pulse_summaries.count) as count",
38
+ "SUM(rails_pulse_summaries.error_count) as error_count",
39
+ "SUM(rails_pulse_summaries.success_count) as success_count"
40
+ )
41
+
42
+ # Apply sorting based on ransack sorts or use default
43
+ if has_sorts
44
+ # Apply custom sorting based on ransack parameters
45
+ sort = @ransack_query.sorts.first
46
+ direction = sort.dir == "desc" ? :desc : :asc
47
+
48
+ case sort.name
49
+ when "avg_duration"
50
+ grouped_query = grouped_query.order(Arel.sql("AVG(rails_pulse_summaries.avg_duration)").send(direction))
51
+ when "max_duration"
52
+ grouped_query = grouped_query.order(Arel.sql("MAX(rails_pulse_summaries.max_duration)").send(direction))
53
+ when "min_duration"
54
+ grouped_query = grouped_query.order(Arel.sql("MIN(rails_pulse_summaries.min_duration)").send(direction))
55
+ when "count"
56
+ grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count)").send(direction))
57
+ when "requests_per_minute"
58
+ grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count) / 60.0").send(direction))
59
+ when "error_rate_percentage"
60
+ grouped_query = grouped_query.order(Arel.sql("(SUM(rails_pulse_summaries.error_count) * 100.0) / SUM(rails_pulse_summaries.count)").send(direction))
61
+ when "period_start"
62
+ grouped_query = grouped_query.order(period_start: direction)
63
+ else
64
+ # Unknown sort field, fallback to default
65
+ grouped_query = grouped_query.order(period_start: :desc)
66
+ end
67
+ else
68
+ # Apply default sort when no explicit sort is provided (matches controller default_table_sort)
69
+ grouped_query = grouped_query.order(period_start: :desc)
70
+ end
71
+
72
+ grouped_query
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -62,7 +62,7 @@ module RailsPulse
62
62
  context: "routes",
63
63
  title: "Average Response Time",
64
64
  summary: "#{average_response_time} ms",
65
- line_chart_data: sparkline_data,
65
+ chart_data: sparkline_data,
66
66
  trend_icon: trend_icon,
67
67
  trend_amount: trend_amount,
68
68
  trend_text: "Compared to last week"
@@ -59,7 +59,7 @@ module RailsPulse
59
59
  context: "routes",
60
60
  title: "Error Rate Per Route",
61
61
  summary: "#{overall_error_rate}%",
62
- line_chart_data: sparkline_data,
62
+ chart_data: sparkline_data,
63
63
  trend_icon: trend_icon,
64
64
  trend_amount: trend_amount,
65
65
  trend_text: "Compared to last week"
@@ -53,7 +53,7 @@ module RailsPulse
53
53
  context: "routes",
54
54
  title: "95th Percentile Response Time",
55
55
  summary: "#{p95_response_time} ms",
56
- line_chart_data: sparkline_data,
56
+ chart_data: sparkline_data,
57
57
  trend_icon: trend_icon,
58
58
  trend_amount: trend_amount,
59
59
  trend_text: "Compared to last week"
@@ -48,16 +48,27 @@ module RailsPulse
48
48
  sparkline_data[label] = { value: total }
49
49
  end
50
50
 
51
- # Calculate average requests per minute over 2-week period
52
- total_minutes = 2.weeks / 1.minute
53
- average_requests_per_minute = total_request_count / total_minutes
51
+ # Calculate appropriate rate display based on frequency
52
+ total_minutes = 2.weeks / 1.minute.to_f
53
+ requests_per_minute = total_request_count.to_f / total_minutes
54
+
55
+ # Choose appropriate time unit for display
56
+ if requests_per_minute >= 1
57
+ summary = "#{requests_per_minute.round(2)} / min"
58
+ elsif requests_per_minute * 60 >= 1
59
+ requests_per_hour = requests_per_minute * 60
60
+ summary = "#{requests_per_hour.round(2)} / hour"
61
+ else
62
+ requests_per_day = requests_per_minute * 60 * 24
63
+ summary = "#{requests_per_day.round(2)} / day"
64
+ end
54
65
 
55
66
  {
56
67
  id: "request_count_totals",
57
68
  context: "routes",
58
69
  title: "Request Count Total",
59
- summary: "#{average_requests_per_minute.round(2)} / min",
60
- line_chart_data: sparkline_data,
70
+ summary: summary,
71
+ chart_data: sparkline_data,
61
72
  trend_icon: trend_icon,
62
73
  trend_amount: trend_amount,
63
74
  trend_text: "Compared to last week"
@@ -13,7 +13,9 @@ module RailsPulse
13
13
  # Check if we have explicit ransack sorts
14
14
  has_sorts = @ransack_query.sorts.any?
15
15
 
16
- base_query = @ransack_query.result(distinct: false)
16
+ # Store sorts for later and get result without ordering
17
+ # This prevents PostgreSQL GROUP BY issues with ORDER BY columns
18
+ base_query = @ransack_query.result(distinct: false).reorder(nil)
17
19
  .joins("INNER JOIN rails_pulse_routes ON rails_pulse_routes.id = rails_pulse_summaries.summarizable_id")
18
20
  .where(
19
21
  summarizable_type: "RailsPulse::Route",
@@ -55,7 +57,7 @@ module RailsPulse
55
57
  grouped_query = grouped_query.order(Arel.sql("AVG(rails_pulse_summaries.avg_duration)").send(direction))
56
58
  when "max_duration_sort"
57
59
  grouped_query = grouped_query.order(Arel.sql("MAX(rails_pulse_summaries.max_duration)").send(direction))
58
- when "count_sort"
60
+ when "count_sort", "request_count_sort"
59
61
  grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count)").send(direction))
60
62
  when "requests_per_minute"
61
63
  grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count) / 60.0").send(direction))
@@ -36,9 +36,10 @@ module RailsPulse
36
36
  # Ransack configuration
37
37
  def self.ransackable_attributes(auth_object = nil)
38
38
  %w[
39
- period_start period_end avg_duration max_duration count error_count
39
+ period_start period_end avg_duration min_duration max_duration count error_count
40
40
  requests_per_minute error_rate_percentage route_path_cont
41
41
  execution_count total_time_consumed normalized_sql
42
+ summarizable_id summarizable_type
42
43
  ]
43
44
  end
44
45
 
@@ -46,17 +47,16 @@ module RailsPulse
46
47
  %w[route query]
47
48
  end
48
49
 
49
- # Custom ransackers for calculated fields (designed to work with GROUP BY queries)
50
- ransacker :count do
51
- Arel.sql("SUM(rails_pulse_summaries.count)") # Use SUM for proper grouping
52
- end
50
+ # Note: Basic fields like count, avg_duration, min_duration, max_duration
51
+ # are handled automatically by Ransack using actual database columns
53
52
 
53
+ # Custom ransackers for calculated fields only
54
54
  ransacker :requests_per_minute do
55
- Arel.sql("SUM(rails_pulse_summaries.count) / 60.0") # Use SUM for consistency
55
+ Arel.sql("rails_pulse_summaries.count / 60.0")
56
56
  end
57
57
 
58
58
  ransacker :error_rate_percentage do
59
- Arel.sql("(SUM(rails_pulse_summaries.error_count) * 100.0) / SUM(rails_pulse_summaries.count)") # Use SUM for both
59
+ Arel.sql("(rails_pulse_summaries.error_count * 100.0) / rails_pulse_summaries.count")
60
60
  end
61
61
 
62
62
 
@@ -38,9 +38,17 @@ module RailsPulse
38
38
 
39
39
  def count_tables
40
40
  tables = []
41
- tables.concat(sql.scan(/FROM\s+(\w+)/i).flatten)
42
- tables.concat(sql.scan(/JOIN\s+(\w+)/i).flatten)
43
- tables.uniq.length
41
+
42
+ # Match FROM clause with various table name formats
43
+ # Handles: table_name, schema.table, "quoted_table", `backtick_table`
44
+ tables.concat(sql.scan(/FROM\s+(?:`([^`]+)`|"([^"]+)"|'([^']+)'|(\w+(?:\.\w+)?))/i).flatten.compact)
45
+
46
+ # Match JOIN clauses (INNER JOIN, LEFT JOIN, etc.)
47
+ tables.concat(sql.scan(/(?:INNER\s+|LEFT\s+|RIGHT\s+|FULL\s+|CROSS\s+)?JOIN\s+(?:`([^`]+)`|"([^"]+)"|'([^']+)'|(\w+(?:\.\w+)?))/i).flatten.compact)
48
+
49
+ # Remove schema prefixes for uniqueness check (schema.table -> table)
50
+ normalized_tables = tables.map { |table| table.split(".").last }
51
+ normalized_tables.uniq.length
44
52
  end
45
53
 
46
54
  def count_joins
@@ -4,7 +4,7 @@
4
4
  context = data[:context]
5
5
  title = data[:title]
6
6
  summary = data[:summary]
7
- line_chart_data = data[:line_chart_data]
7
+ chart_data = data[:chart_data]
8
8
  trend_icon = data[:trend_icon]
9
9
  trend_amount = data[:trend_amount]
10
10
  trend_text = data[:trend_text]
@@ -38,7 +38,7 @@
38
38
  }
39
39
  )
40
40
  %>
41
- <%= bar_chart line_chart_data, height: "100%", options: chart_options %>
41
+ <%= bar_chart chart_data, height: "100%", options: chart_options %>
42
42
  </div>
43
43
  </div>
44
44
  </div>
@@ -26,7 +26,7 @@
26
26
  <div>
27
27
  <h4 class="text-xs font-medium text-subtle uppercase">Occurred At</h4>
28
28
  <div class="text-sm">
29
- <%= operation.occurred_at.strftime("%H:%M:%S.%L") %>
29
+ <%= operation.occurred_at.getlocal.strftime("%H:%M:%S.%L") %>
30
30
  </div>
31
31
  </div>
32
32
  <% end %>
@@ -2,7 +2,7 @@
2
2
  <h4 class="text-xl mbs-1 font-bold"><%= summary %></h4>
3
3
  </div>
4
4
  <div class="chart-container chart-container--slim">
5
- <%= bar_chart line_chart_data, height: "100%", options: sparkline_chart_options %>
5
+ <%= bar_chart chart_data, height: "100%", options: sparkline_chart_options %>
6
6
  </div>
7
7
  <div>
8
8
  <span class="badge badge--<%= trend_direction == "down" ? "positive" : "negative" %>-inverse p-0">
@@ -71,7 +71,7 @@
71
71
  <div class="grid-item">
72
72
  <%= render 'rails_pulse/components/panel', {
73
73
  title: 'Slowest Queries This Week',
74
- help_heading: 'Slowest Queries',
74
+ help_heading: 'Slowest Queries',
75
75
  help_text: 'This panel shows the slowest database queries in your application this week, including average execution time and when they were last seen.',
76
76
  actions: [{ url: queries_path, icon: 'external-link', title: 'View details', data: { turbo_frame: '_top' } }],
77
77
  card_classes: 'table-container'
@@ -12,7 +12,37 @@
12
12
  <dd><%= query.query_stats['table_count'] || 0 %></dd>
13
13
  <dt>Joins</dt>
14
14
  <dd><%= query.query_stats['join_count'] || 0 %></dd>
15
- <dt>Complexity Score</dt>
15
+ <dt>
16
+ Complexity Score
17
+ <div data-controller="rails-pulse--popover" data-rails-pulse--popover-placement-value="bottom-start" style="display: inline-block; margin-left: 4px;">
18
+ <a href="#"
19
+ data-rails-pulse--popover-target="button"
20
+ data-action="rails-pulse--popover#toggle"
21
+ data-popovertarget="complexity-score-popover"
22
+ style="color: var(--gray-500); vertical-align: top;">
23
+ <%= rails_pulse_icon 'info', height: "14px" %>
24
+ </a>
25
+
26
+ <div popover class="popover card" data-rails-pulse--popover-target="menu" style="max-width: 22rem">
27
+ <div class="flex flex-col">
28
+ <h3 class="font-semibold leading-none mbe-2 uppercase text-sm">Complexity Score</h3>
29
+ <p class="text-sm text-subtle mbe-3">
30
+ A calculated score representing query complexity based on multiple factors:
31
+ </p>
32
+ <ul class="text-sm text-subtle" style="list-style-type: disc; padding-left: 16px; margin: 0;">
33
+ <li>Tables: +2 points per table</li>
34
+ <li>Joins: +3 points per join</li>
35
+ <li>WHERE conditions: +1 point per condition, +2 per function</li>
36
+ <li>UNIONs: +4 points each</li>
37
+ <li>Subqueries: +5 points each</li>
38
+ </ul>
39
+ <p class="text-sm text-subtle mbs-3">
40
+ Higher scores indicate more complex queries that may need optimization.
41
+ </p>
42
+ </div>
43
+ </div>
44
+ </div>
45
+ </dt>
16
46
  <dd><%= query.query_stats['estimated_complexity'] || 0 %></dd>
17
47
  <dt>Has LIMIT</dt>
18
48
  <dd><%= query.query_stats['has_limit'] ? 'Yes' : 'No' %></dd>
@@ -52,36 +82,36 @@
52
82
  </div>
53
83
  </div>
54
84
 
85
+
55
86
  <!-- Issues Summary -->
56
87
  <% if query.issues.present? && query.issues.any? %>
57
- <div class="panel panel--danger mb-4">
58
- <h3 class="text-lg bold mb-3">Issues Detected</h3>
59
- <ul>
60
- <% query.issues.each do |issue| %>
61
- <li><%= issue['description'] %></li>
62
- <% end %>
63
- </ul>
64
- </div>
88
+ <hr class="mb-4" />
89
+ <h3 class="text-lg bold">Issues Detected</h3>
90
+ <ul style="list-style-type: disc; padding-left: 20px;">
91
+ <% query.issues.each do |issue| %>
92
+ <li><%= issue['description'] %></li>
93
+ <% end %>
94
+ </ul>
65
95
  <% end %>
66
96
 
97
+
67
98
  <!-- Suggestions -->
68
99
  <% if query.suggestions.present? && query.suggestions.any? %>
69
- <div class="panel panel--info mb-4">
70
- <h3 class="text-lg bold mb-3">Optimization Suggestions</h3>
71
- <ul>
72
- <% query.suggestions.each do |suggestion| %>
73
- <li>
74
- <%= suggestion['action'] %>.
75
- <%= suggestion['benefit'] if suggestion['benefit'].present? %>
76
- </li>
77
- <% end %>
78
- </ul>
79
- </div>
100
+ <hr class="mb-4" />
101
+ <h3 class="text-lg bold">Optimization Suggestions</h3>
102
+ <ul style="list-style-type: disc; padding-left: 20px;">
103
+ <% query.suggestions.each do |suggestion| %>
104
+ <li>
105
+ <%= suggestion['action'] %>.
106
+ <%= suggestion['benefit'] if suggestion['benefit'].present? %>
107
+ </li>
108
+ <% end %>
109
+ </ul>
80
110
  <% end %>
81
111
 
82
112
  <!-- EXPLAIN Plan -->
83
113
  <% if query.explain_plan.present? %>
84
- <%= render 'rails_pulse/components/panel', title: 'Execution Plan' do %>
85
- <pre class="text-sm"><%= query.explain_plan %></pre>
86
- <% end %>
114
+ <hr class="mb-4" />
115
+ <h3 class="text-lg bold">Execution Plan</h3>
116
+ <pre class="text-sm" style="overflow: scroll"><%= query.explain_plan %></pre>
87
117
  <% end %>
@@ -1,16 +1,44 @@
1
1
  <% columns = [
2
- { field: :occurred_at, label: 'Timestamp', class: 'w-auto' },
3
- { field: :duration, label: 'Duration', class: 'w-32'}
2
+ { field: :period_start, label: 'Time Period', class: 'w-auto' },
3
+ { field: :count, label: 'Executions', class: 'w-32'},
4
+ { field: :avg_duration, label: 'Avg Duration', class: 'w-32'},
5
+ { field: :min_duration, label: 'Min Duration', class: 'w-32'},
6
+ { field: :max_duration, label: 'Max Duration', class: 'w-32'}
4
7
  ] %>
5
8
 
6
9
  <table class="table mbs-4" data-controller="rails-pulse--table-sort">
7
10
  <%= render "rails_pulse/components/table_head", columns: columns %>
8
11
 
9
12
  <tbody>
10
- <% @table_data.each do |query| %>
13
+ <% @table_data.each do |summary| %>
14
+ <%
15
+ # Determine performance class based on average duration
16
+ avg_duration_ms = summary.avg_duration&.round(2) || 0
17
+ performance_class = case avg_duration_ms
18
+ when 0..10 then "text-green-600"
19
+ when 10..50 then "text-yellow-600"
20
+ when 50..100 then "text-orange-600"
21
+ else "text-red-600"
22
+ end
23
+ %>
11
24
  <tr>
12
- <td class="whitespace-nowrap"><%= human_readable_occurred_at(query.occurred_at) %></td>
13
- <td class="whitespace-nowrap"><%= query.duration.round(2) %> ms</td>
25
+ <td class="whitespace-nowrap">
26
+ <%= human_readable_summary_period(summary) %>
27
+ </td>
28
+ <td class="whitespace-nowrap text-center">
29
+ <span class="font-medium"><%= summary.count %></span>
30
+ </td>
31
+ <td class="whitespace-nowrap">
32
+ <span class="<%= performance_class %> font-medium">
33
+ <%= avg_duration_ms %> ms
34
+ </span>
35
+ </td>
36
+ <td class="whitespace-nowrap text-center">
37
+ <%= summary.min_duration&.round(2) || 0 %> ms
38
+ </td>
39
+ <td class="whitespace-nowrap text-center">
40
+ <%= summary.max_duration&.round(2) || 0 %> ms
41
+ </td>
14
42
  </tr>
15
43
  <% end %>
16
44
  </tbody>