rails_pulse 0.2.4 → 0.2.5.pre.pre.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +269 -12
  3. data/Rakefile +142 -8
  4. data/app/assets/stylesheets/rails_pulse/components/table.css +16 -1
  5. data/app/assets/stylesheets/rails_pulse/components/tags.css +7 -2
  6. data/app/assets/stylesheets/rails_pulse/components/utilities.css +3 -0
  7. data/app/controllers/concerns/chart_table_concern.rb +2 -1
  8. data/app/controllers/rails_pulse/application_controller.rb +11 -1
  9. data/app/controllers/rails_pulse/assets_controller.rb +18 -2
  10. data/app/controllers/rails_pulse/job_runs_controller.rb +37 -0
  11. data/app/controllers/rails_pulse/jobs_controller.rb +80 -0
  12. data/app/controllers/rails_pulse/operations_controller.rb +43 -31
  13. data/app/controllers/rails_pulse/queries_controller.rb +1 -1
  14. data/app/controllers/rails_pulse/requests_controller.rb +3 -9
  15. data/app/controllers/rails_pulse/routes_controller.rb +1 -1
  16. data/app/controllers/rails_pulse/tags_controller.rb +31 -5
  17. data/app/helpers/rails_pulse/application_helper.rb +32 -1
  18. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +15 -1
  19. data/app/helpers/rails_pulse/status_helper.rb +16 -0
  20. data/app/helpers/rails_pulse/tags_helper.rb +39 -1
  21. data/app/javascript/rails_pulse/controllers/chart_controller.js +112 -8
  22. data/app/models/concerns/rails_pulse/taggable.rb +25 -2
  23. data/app/models/rails_pulse/charts/operations_chart.rb +33 -0
  24. data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +1 -2
  25. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +1 -1
  26. data/app/models/rails_pulse/job.rb +85 -0
  27. data/app/models/rails_pulse/job_run.rb +76 -0
  28. data/app/models/rails_pulse/jobs/cards/average_duration.rb +85 -0
  29. data/app/models/rails_pulse/jobs/cards/base.rb +70 -0
  30. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +85 -0
  31. data/app/models/rails_pulse/jobs/cards/total_jobs.rb +74 -0
  32. data/app/models/rails_pulse/jobs/cards/total_runs.rb +48 -0
  33. data/app/models/rails_pulse/operation.rb +16 -3
  34. data/app/models/rails_pulse/queries/cards/average_query_times.rb +3 -3
  35. data/app/models/rails_pulse/queries/cards/execution_rate.rb +1 -1
  36. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +1 -1
  37. data/app/models/rails_pulse/queries/tables/index.rb +2 -1
  38. data/app/models/rails_pulse/query.rb +10 -1
  39. data/app/models/rails_pulse/routes/cards/average_response_times.rb +3 -2
  40. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +1 -1
  41. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +1 -1
  42. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +1 -1
  43. data/app/models/rails_pulse/routes/tables/index.rb +2 -1
  44. data/app/models/rails_pulse/summary.rb +10 -3
  45. data/app/services/rails_pulse/summary_service.rb +46 -0
  46. data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
  47. data/app/views/layouts/rails_pulse/application.html.erb +23 -0
  48. data/app/views/rails_pulse/components/_active_filters.html.erb +7 -6
  49. data/app/views/rails_pulse/components/_page_header.html.erb +8 -7
  50. data/app/views/rails_pulse/components/_table.html.erb +7 -4
  51. data/app/views/rails_pulse/dashboard/index.html.erb +1 -1
  52. data/app/views/rails_pulse/job_runs/_operations.html.erb +78 -0
  53. data/app/views/rails_pulse/job_runs/index.html.erb +3 -0
  54. data/app/views/rails_pulse/job_runs/show.html.erb +51 -0
  55. data/app/views/rails_pulse/jobs/_job_runs_table.html.erb +35 -0
  56. data/app/views/rails_pulse/jobs/_table.html.erb +43 -0
  57. data/app/views/rails_pulse/jobs/index.html.erb +34 -0
  58. data/app/views/rails_pulse/jobs/show.html.erb +49 -0
  59. data/app/views/rails_pulse/operations/_operation_analysis_application.html.erb +29 -27
  60. data/app/views/rails_pulse/operations/_operation_analysis_view.html.erb +11 -9
  61. data/app/views/rails_pulse/operations/show.html.erb +10 -8
  62. data/app/views/rails_pulse/queries/_table.html.erb +3 -3
  63. data/app/views/rails_pulse/requests/_table.html.erb +6 -6
  64. data/app/views/rails_pulse/routes/_table.html.erb +3 -3
  65. data/app/views/rails_pulse/routes/show.html.erb +1 -1
  66. data/app/views/rails_pulse/tags/_tag_manager.html.erb +7 -14
  67. data/config/brakeman.ignore +213 -0
  68. data/config/brakeman.yml +68 -0
  69. data/config/initializers/rails_pulse.rb +52 -0
  70. data/config/routes.rb +6 -0
  71. data/db/rails_pulse_migrate/20250113000000_add_jobs_to_rails_pulse.rb +95 -0
  72. data/db/rails_pulse_migrate/20250122000000_add_query_fingerprinting.rb +150 -0
  73. data/db/rails_pulse_migrate/20250202000000_add_index_to_request_uuid.rb +14 -0
  74. data/db/rails_pulse_schema.rb +186 -103
  75. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +186 -103
  76. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +30 -1
  77. data/lib/generators/rails_pulse/templates/rails_pulse.rb +31 -0
  78. data/lib/rails_pulse/active_job_extensions.rb +13 -0
  79. data/lib/rails_pulse/adapters/delayed_job_plugin.rb +25 -0
  80. data/lib/rails_pulse/adapters/sidekiq_middleware.rb +41 -0
  81. data/lib/rails_pulse/cleanup_service.rb +65 -0
  82. data/lib/rails_pulse/configuration.rb +80 -7
  83. data/lib/rails_pulse/engine.rb +34 -3
  84. data/lib/rails_pulse/extensions/active_record.rb +82 -0
  85. data/lib/rails_pulse/job_run_collector.rb +172 -0
  86. data/lib/rails_pulse/middleware/request_collector.rb +20 -43
  87. data/lib/rails_pulse/subscribers/operation_subscriber.rb +11 -5
  88. data/lib/rails_pulse/tracker.rb +82 -0
  89. data/lib/rails_pulse/version.rb +1 -1
  90. data/lib/rails_pulse.rb +2 -0
  91. data/lib/rails_pulse_server.ru +107 -0
  92. data/lib/tasks/rails_pulse_benchmark.rake +382 -0
  93. data/public/rails-pulse-assets/rails-pulse-icons.js +3 -2
  94. data/public/rails-pulse-assets/rails-pulse-icons.js.map +1 -1
  95. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  96. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  97. data/public/rails-pulse-assets/rails-pulse.js +1 -1
  98. data/public/rails-pulse-assets/rails-pulse.js.map +3 -3
  99. metadata +37 -9
  100. data/app/models/rails_pulse/requests/charts/operations_chart.rb +0 -35
  101. data/db/migrate/20250930105043_install_rails_pulse_tables.rb +0 -23
@@ -0,0 +1,74 @@
1
+ module RailsPulse
2
+ module Jobs
3
+ module Cards
4
+ class TotalJobs < Base
5
+ def initialize(job: nil)
6
+ @job = job
7
+ end
8
+
9
+ def to_metric_card
10
+ # When scoped to a job, show runs count instead of job count
11
+ if @job
12
+ base_query = RailsPulse::Summary
13
+ .where(
14
+ summarizable_type: "RailsPulse::Job",
15
+ summarizable_id: @job.id,
16
+ period_type: "day",
17
+ period_start: range_start..now
18
+ )
19
+
20
+ metrics = base_query.select(
21
+ "SUM(count) AS total_count",
22
+ "SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN count ELSE 0 END) AS current_count",
23
+ "SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN count ELSE 0 END) AS previous_count"
24
+ ).take
25
+
26
+ total_runs = metrics&.total_count.to_i
27
+ current_runs = metrics&.current_count.to_i
28
+ previous_runs = metrics&.previous_count.to_i
29
+
30
+ trend_icon, trend_amount = trend_for(current_runs, previous_runs)
31
+
32
+ grouped_runs = base_query
33
+ .group_by_date(:period_start)
34
+ .sum(:count)
35
+
36
+ {
37
+ id: "jobs_total_jobs",
38
+ context: "jobs",
39
+ title: "Total Runs",
40
+ summary: "#{format_number(total_runs)} runs",
41
+ chart_data: sparkline_from(grouped_runs),
42
+ trend_icon: trend_icon,
43
+ trend_amount: trend_amount,
44
+ trend_text: "Compared to previous week"
45
+ }
46
+ else
47
+ total_jobs = RailsPulse::Job.count
48
+
49
+ current_new_jobs = RailsPulse::Job.where(created_at: current_window_start..now).count
50
+ previous_new_jobs = RailsPulse::Job.where(created_at: range_start...current_window_start).count
51
+
52
+ trend_icon, trend_amount = trend_for(current_new_jobs, previous_new_jobs)
53
+
54
+ grouped_new_jobs = RailsPulse::Job
55
+ .where(created_at: range_start..now)
56
+ .group_by_date(:created_at)
57
+ .count
58
+
59
+ {
60
+ id: "jobs_total_jobs",
61
+ context: "jobs",
62
+ title: "Total Jobs",
63
+ summary: "#{format_number(total_jobs)} jobs",
64
+ chart_data: sparkline_from(grouped_new_jobs),
65
+ trend_icon: trend_icon,
66
+ trend_amount: trend_amount,
67
+ trend_text: "New jobs vs previous week"
68
+ }
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,48 @@
1
+ module RailsPulse
2
+ module Jobs
3
+ module Cards
4
+ class TotalRuns < Base
5
+ def initialize(job: nil)
6
+ @job = job
7
+ end
8
+
9
+ def to_metric_card
10
+ base_query = RailsPulse::Summary
11
+ .where(
12
+ summarizable_type: "RailsPulse::Job",
13
+ period_type: "day",
14
+ period_start: range_start..now
15
+ )
16
+ base_query = base_query.where(summarizable_id: @job.id) if @job
17
+
18
+ metrics = base_query.select(
19
+ "SUM(count) AS total_count",
20
+ "SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN count ELSE 0 END) AS current_count",
21
+ "SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN count ELSE 0 END) AS previous_count"
22
+ ).take
23
+
24
+ total_runs = metrics&.total_count.to_i
25
+ current_runs = metrics&.current_count.to_i
26
+ previous_runs = metrics&.previous_count.to_i
27
+
28
+ trend_icon, trend_amount = trend_for(current_runs, previous_runs)
29
+
30
+ grouped_runs = base_query
31
+ .group_by_date(:period_start)
32
+ .sum(:count)
33
+
34
+ {
35
+ id: "jobs_total_runs",
36
+ context: "jobs",
37
+ title: "Job Runs",
38
+ summary: "#{format_number(total_runs)} runs",
39
+ chart_data: sparkline_from(grouped_runs),
40
+ trend_icon: trend_icon,
41
+ trend_amount: trend_amount,
42
+ trend_text: "Compared to previous week"
43
+ }
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,3 +1,5 @@
1
+ require "digest"
2
+
1
3
  module RailsPulse
2
4
  class Operation < RailsPulse::ApplicationRecord
3
5
  self.table_name = "rails_pulse_operations"
@@ -18,15 +20,16 @@ module RailsPulse
18
20
  ].freeze
19
21
 
20
22
  # Associations
21
- belongs_to :request, class_name: "RailsPulse::Request"
23
+ belongs_to :request, class_name: "RailsPulse::Request", optional: true
24
+ belongs_to :job_run, class_name: "RailsPulse::JobRun", optional: true
22
25
  belongs_to :query, class_name: "RailsPulse::Query", optional: true
23
26
 
24
27
  # Validations
25
- validates :request_id, presence: true
26
28
  validates :operation_type, presence: true, inclusion: { in: OPERATION_TYPES }
27
29
  validates :label, presence: true
28
30
  validates :occurred_at, presence: true
29
31
  validates :duration, presence: true, numericality: { greater_than_or_equal_to: 0 }
32
+ validate :has_request_or_job_run
30
33
 
31
34
  # Scopes (optional, for convenience)
32
35
  scope :by_type, ->(type) { where(operation_type: type) }
@@ -72,11 +75,21 @@ module RailsPulse
72
75
 
73
76
  private
74
77
 
78
+ def has_request_or_job_run
79
+ return if request_id.present? || job_run_id.present?
80
+
81
+ errors.add(:base, "Operation must belong to a request or a job run")
82
+ end
83
+
75
84
  def associate_query
76
85
  return unless operation_type == "sql" && label.present?
77
86
 
78
87
  normalized = normalize_query_label(label)
79
- self.query = RailsPulse::Query.find_or_create_by(normalized_sql: normalized)
88
+ hashed = Digest::MD5.hexdigest(normalized)
89
+
90
+ self.query = RailsPulse::Query.find_or_create_by(hashed_sql: hashed) do |q|
91
+ q.normalized_sql = normalized
92
+ end
80
93
  end
81
94
 
82
95
  # Normalize SQL query using the dedicated service
@@ -41,13 +41,13 @@ module RailsPulse
41
41
  trend_amount = previous_period_avg.zero? ? "0%" : "#{percentage}%"
42
42
 
43
43
  # Sparkline data by day with zero-filled days over the last 14 days
44
- # Use Groupdate to get grouped sums and compute weighted averages per day
44
+ # Use database-agnostic date grouping (works regardless of ActiveRecord.default_timezone)
45
45
  grouped_weighted = base_query
46
- .group_by_day(:period_start, time_zone: "UTC")
46
+ .group_by_date(:period_start)
47
47
  .sum(Arel.sql("avg_duration * count"))
48
48
 
49
49
  grouped_counts = base_query
50
- .group_by_day(:period_start, time_zone: "UTC")
50
+ .group_by_date(:period_start)
51
51
  .sum(:count)
52
52
 
53
53
  # Build a continuous 14-day range, fill missing days with 0
@@ -52,7 +52,7 @@ module RailsPulse
52
52
  # Sparkline data with zero-filled periods over the last 14 days
53
53
  if period_type == "day"
54
54
  grouped_data = base_query
55
- .group_by_day(:period_start, time_zone: "UTC")
55
+ .group_by_date(:period_start)
56
56
  .sum(:count)
57
57
 
58
58
  start_period = 2.weeks.ago.beginning_of_day.to_date
@@ -39,7 +39,7 @@ module RailsPulse
39
39
 
40
40
  # Sparkline data by day with zero-filled days over the last 14 days
41
41
  grouped_daily = base_query
42
- .group_by_day(:period_start, time_zone: "UTC")
42
+ .group_by_date(:period_start)
43
43
  .average(:p95_duration)
44
44
 
45
45
  start_day = 2.weeks.ago.beginning_of_day.to_date
@@ -29,7 +29,8 @@ module RailsPulse
29
29
 
30
30
  # Exclude queries with actual disabled tags
31
31
  actual_disabled_tags.each do |tag|
32
- base_query = base_query.where.not("rails_pulse_queries.tags LIKE ?", "%#{tag}%")
32
+ sanitized_tag = ActiveRecord::Base.sanitize_sql_like(tag.to_s, "\\")
33
+ base_query = base_query.where.not("rails_pulse_queries.tags LIKE ?", "%#{sanitized_tag}%")
33
34
  end
34
35
 
35
36
  # Exclude non-tagged queries if show_non_tagged is false
@@ -9,7 +9,11 @@ module RailsPulse
9
9
  has_many :summaries, as: :summarizable, class_name: "RailsPulse::Summary", dependent: :destroy
10
10
 
11
11
  # Validations
12
- validates :normalized_sql, presence: true, uniqueness: true
12
+ validates :normalized_sql, presence: true
13
+ validates :hashed_sql, presence: true, uniqueness: true
14
+
15
+ # Callbacks
16
+ before_validation :generate_hashed_sql, if: -> { normalized_sql.present? && (normalized_sql_changed? || hashed_sql.blank?) }
13
17
 
14
18
  # JSON serialization for analysis columns
15
19
  serialize :issues, type: Array, coder: JSON
@@ -103,5 +107,10 @@ module RailsPulse
103
107
  def to_s
104
108
  id
105
109
  end
110
+
111
+ def generate_hashed_sql
112
+ require "digest"
113
+ self.hashed_sql = Digest::MD5.hexdigest(normalized_sql)
114
+ end
106
115
  end
107
116
  end
@@ -41,12 +41,13 @@ module RailsPulse
41
41
  trend_amount = previous_period_avg.zero? ? "0%" : "#{percentage}%"
42
42
 
43
43
  # Sparkline data by day with zero-filled days over the last 14 days
44
+ # Use database-agnostic date grouping (works regardless of ActiveRecord.default_timezone)
44
45
  grouped_weighted = base_query
45
- .group_by_day(:period_start, time_zone: "UTC")
46
+ .group_by_date(:period_start)
46
47
  .sum(Arel.sql("avg_duration * count"))
47
48
 
48
49
  grouped_counts = base_query
49
- .group_by_day(:period_start, time_zone: "UTC")
50
+ .group_by_date(:period_start)
50
51
  .sum(:count)
51
52
 
52
53
  start_day = 2.weeks.ago.beginning_of_day.to_date
@@ -45,7 +45,7 @@ module RailsPulse
45
45
 
46
46
  # Sparkline data by day with zero-filled days over the last 14 days
47
47
  grouped_daily = base_query
48
- .group_by_day(:period_start, time_zone: "UTC")
48
+ .group_by_date(:period_start)
49
49
  .sum(:error_count)
50
50
 
51
51
  start_day = 2.weeks.ago.beginning_of_day.to_date
@@ -39,7 +39,7 @@ module RailsPulse
39
39
 
40
40
  # Sparkline data by day with zero-filled days over the last 14 days
41
41
  grouped_daily = base_query
42
- .group_by_day(:period_start, time_zone: "UTC")
42
+ .group_by_date(:period_start)
43
43
  .average(:p95_duration)
44
44
 
45
45
  start_day = 2.weeks.ago.beginning_of_day.to_date
@@ -39,7 +39,7 @@ module RailsPulse
39
39
 
40
40
  # Sparkline data by day with zero-filled days over the last 14 days
41
41
  grouped_daily = base_query
42
- .group_by_day(:period_start, time_zone: "UTC")
42
+ .group_by_date(:period_start)
43
43
  .sum(:count)
44
44
 
45
45
  start_day = 2.weeks.ago.beginning_of_day.to_date
@@ -30,7 +30,8 @@ module RailsPulse
30
30
 
31
31
  # Exclude routes with actual disabled tags
32
32
  actual_disabled_tags.each do |tag|
33
- base_query = base_query.where.not("rails_pulse_routes.tags LIKE ?", "%#{tag}%")
33
+ sanitized_tag = ActiveRecord::Base.sanitize_sql_like(tag.to_s, "\\")
34
+ base_query = base_query.where.not("rails_pulse_routes.tags LIKE ?", "%#{sanitized_tag}%")
34
35
  end
35
36
 
36
37
  # Exclude non-tagged routes if show_non_tagged is false
@@ -12,6 +12,9 @@ module RailsPulse
12
12
  foreign_key: "summarizable_id", class_name: "RailsPulse::Route", optional: true
13
13
  belongs_to :query, -> { where(rails_pulse_summaries: { summarizable_type: "RailsPulse::Query" }) },
14
14
  foreign_key: "summarizable_id", class_name: "RailsPulse::Query", optional: true
15
+ belongs_to :job,
16
+ -> { where(rails_pulse_summaries: { summarizable_type: "RailsPulse::Job" }) },
17
+ foreign_key: "summarizable_id", class_name: "RailsPulse::Job", optional: true
15
18
 
16
19
  # Validations
17
20
  validates :period_type, inclusion: { in: PERIOD_TYPES }
@@ -26,6 +29,8 @@ module RailsPulse
26
29
  scope :for_requests, -> { where(summarizable_type: "RailsPulse::Request") }
27
30
  scope :for_routes, -> { where(summarizable_type: "RailsPulse::Route") }
28
31
  scope :for_queries, -> { where(summarizable_type: "RailsPulse::Query") }
32
+ scope :for_jobs, -> { where(summarizable_type: "RailsPulse::Job") }
33
+ scope :by_job_name, ->(job_name) { for_jobs.joins(:job).where(rails_pulse_jobs: { name: job_name }) }
29
34
  scope :recent, -> { order(period_start: :desc) }
30
35
 
31
36
  # Special scope for overall request summaries
@@ -51,7 +56,8 @@ module RailsPulse
51
56
 
52
57
  # Exclude routes with disabled tags
53
58
  actual_disabled_tags.each do |tag|
54
- route_ids = route_ids.where.not("tags LIKE ?", "%#{tag}%")
59
+ sanitized_tag = ActiveRecord::Base.sanitize_sql_like(tag.to_s, "\\")
60
+ route_ids = route_ids.where.not("tags LIKE ?", "%#{sanitized_tag}%")
55
61
  end
56
62
 
57
63
  # Exclude non-tagged routes if show_non_tagged is false
@@ -64,7 +70,8 @@ module RailsPulse
64
70
 
65
71
  # Exclude queries with disabled tags
66
72
  actual_disabled_tags.each do |tag|
67
- query_ids = query_ids.where.not("tags LIKE ?", "%#{tag}%")
73
+ sanitized_tag = ActiveRecord::Base.sanitize_sql_like(tag.to_s, "\\")
74
+ query_ids = query_ids.where.not("tags LIKE ?", "%#{sanitized_tag}%")
68
75
  end
69
76
 
70
77
  # Exclude non-tagged queries if show_non_tagged is false
@@ -99,7 +106,7 @@ module RailsPulse
99
106
  end
100
107
 
101
108
  def self.ransackable_associations(auth_object = nil)
102
- %w[route query]
109
+ %w[route query job]
103
110
  end
104
111
 
105
112
  # Note: Basic fields like count, avg_duration, min_duration, max_duration
@@ -16,6 +16,7 @@ module RailsPulse
16
16
  aggregate_requests # Overall system metrics
17
17
  aggregate_routes # Per-route metrics
18
18
  aggregate_queries # Per-query metrics
19
+ aggregate_jobs # Per-job metrics
19
20
  end
20
21
 
21
22
  Rails.logger.info "[RailsPulse] Completed #{period_type} summary"
@@ -180,6 +181,51 @@ module RailsPulse
180
181
  end
181
182
  end
182
183
 
184
+ def aggregate_jobs
185
+ job_runs = JobRun
186
+ .includes(:job)
187
+ .where(occurred_at: start_time...end_time)
188
+ .where(status: JobRun::FINAL_STATUSES)
189
+
190
+ return if job_runs.empty?
191
+
192
+ job_runs.group_by(&:job_id).each do |job_id, runs|
193
+ job = runs.first&.job
194
+ next unless job
195
+
196
+ duration_values = runs.map(&:duration).compact.map(&:to_f).sort
197
+ next if duration_values.empty?
198
+
199
+ duration_count = duration_values.size
200
+ total_duration = duration_values.sum
201
+ average_duration = total_duration / duration_count
202
+
203
+ summary = Summary.find_or_initialize_by(
204
+ summarizable_type: "RailsPulse::Job",
205
+ summarizable_id: job.id,
206
+ period_type: period_type,
207
+ period_start: start_time
208
+ )
209
+
210
+ summary.assign_attributes(
211
+ period_end: end_time,
212
+ count: runs.size,
213
+ avg_duration: average_duration,
214
+ min_duration: duration_values.first,
215
+ max_duration: duration_values.last,
216
+ total_duration: total_duration,
217
+ p50_duration: calculate_percentile(duration_values, 0.5),
218
+ p95_duration: calculate_percentile(duration_values, 0.95),
219
+ p99_duration: calculate_percentile(duration_values, 0.99),
220
+ stddev_duration: calculate_stddev(duration_values, average_duration),
221
+ error_count: runs.count(&:failure_like_status?),
222
+ success_count: runs.count { |run| run.status == "success" }
223
+ )
224
+
225
+ summary.save!
226
+ end
227
+ end
228
+
183
229
  def calculate_percentile(sorted_array, percentile)
184
230
  return nil if sorted_array.empty?
185
231
 
@@ -17,3 +17,10 @@
17
17
  <%= rails_pulse_icon 'audio-lines', width: '16' %>
18
18
  <span class="overflow-ellipsis">Requests</span>
19
19
  <% end %>
20
+
21
+ <% if RailsPulse.configuration.track_jobs %>
22
+ <%= link_to jobs_path, class: 'btn sidebar-menu__button' do %>
23
+ <%= rails_pulse_icon 'zap', width: '16' %>
24
+ <span class="overflow-ellipsis">Jobs</span>
25
+ <% end %>
26
+ <% end %>
@@ -10,6 +10,29 @@
10
10
 
11
11
  <%= yield :head %>
12
12
 
13
+ <script nonce="<%= rails_pulse_csp_nonce %>">
14
+ (function() {
15
+ var storageKey = 'color-scheme';
16
+ var root = document.documentElement;
17
+ var scheme = 'light';
18
+
19
+ try {
20
+ var stored = localStorage.getItem(storageKey);
21
+ if (stored === 'dark' || stored === 'light') {
22
+ scheme = stored;
23
+ } else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
24
+ scheme = 'dark';
25
+ }
26
+ } catch (error) {
27
+ if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
28
+ scheme = 'dark';
29
+ }
30
+ }
31
+
32
+ root.setAttribute('data-color-scheme', scheme);
33
+ })();
34
+ </script>
35
+
13
36
  <!-- Rails Pulse Pre-compiled Assets (CSP-safe) -->
14
37
  <%= stylesheet_link_tag rails_pulse.asset_path('rails-pulse.css'), 'data-turbo-track': 'reload' %>
15
38
  <%= javascript_include_tag rails_pulse.asset_path('rails-pulse-icons.js'), 'data-turbo-track': 'reload', defer: true, nonce: rails_pulse_csp_nonce %>
@@ -6,12 +6,13 @@
6
6
  <% has_any_filters = has_date_filters || has_performance_filter || has_tag_filters %>
7
7
 
8
8
  <% if has_any_filters %>
9
- <div class="flex items-center gap text-sm">
10
- Filtered:
9
+ <div class="flex items-center gap text-sm card pi-3 pb-2 shadow-xs">
10
+ Global Filters:
11
11
  <% if has_date_filters %>
12
12
  <% start_time = Time.parse(global_filters['start_time']) %>
13
13
  <% end_time = Time.parse(global_filters['end_time']) %>
14
- <span class="badge badge--secondary"><%= start_time.strftime("%b %d, %Y %-I:%M %p") %> - <%= end_time.strftime("%b %d, %Y %-I:%M %p") %></span>
14
+ <% date_range = "#{start_time.strftime("%b %d, %Y %-I:%M %p")} - #{end_time.strftime("%b %d, %Y %-I:%M %p")}" %>
15
+ <%= render_tag_badge(date_range, variant: :secondary) %>
15
16
  <% end %>
16
17
 
17
18
  <% if has_performance_filter %>
@@ -21,15 +22,15 @@
21
22
  when 'critical' then 'Critical'
22
23
  else global_filters['performance_threshold'].titleize
23
24
  end %>
24
- <span class="badge badge--secondary"><%= threshold_label %></span>
25
+ <%= render_tag_badge(threshold_label, variant: :secondary) %>
25
26
  <% end %>
26
27
 
27
28
  <% if has_tag_filters %>
28
29
  <% disabled_tags.each do |tag| %>
29
- <span class="badge badge--secondary"><%= tag.humanize %></span>
30
+ <%= render_tag_badge(tag.humanize, variant: :secondary) %>
30
31
  <% end %>
31
32
  <% if session[:show_non_tagged] == false %>
32
- <span class="badge badge--secondary">Non tagged hidden</span>
33
+ <%= render_tag_badge("Non tagged hidden", variant: :secondary) %>
33
34
  <% end %>
34
35
  <% end %>
35
36
  </div>
@@ -12,13 +12,14 @@
12
12
  <% end %>
13
13
  </nav>
14
14
 
15
- <% if defined?(taggable) && taggable.present? %>
16
- <div class="breadcrumb-tags">
17
- <%= render 'rails_pulse/tags/tag_manager', taggable: taggable %>
18
- </div>
19
- <% elsif defined?(show_active_filters) && show_active_filters %>
20
- <div class="breadcrumb-tags">
21
- <%= render 'rails_pulse/components/active_filters' %>
15
+ <% if (defined?(taggable) && taggable.present?) || (defined?(show_active_filters) && show_active_filters) %>
16
+ <div class="breadcrumb-tags gap">
17
+ <% if defined?(show_active_filters) && show_active_filters %>
18
+ <%= render 'rails_pulse/components/active_filters' %>
19
+ <% end %>
20
+ <% if defined?(taggable) && taggable.present? %>
21
+ <%= render 'rails_pulse/tags/tag_manager', taggable: taggable %>
22
+ <% end %>
22
23
  </div>
23
24
  <% end %>
24
25
  </div>
@@ -1,7 +1,7 @@
1
1
  <%
2
2
  # Extract the table data from the arguments
3
3
  table_data = defined?(table_data) ? table_data : (defined?(data) ? data : {})
4
-
4
+
5
5
  # Handle both old format (array) and new format (hash with columns and data)
6
6
  if table_data.is_a?(Array)
7
7
  # Legacy format - assume route data
@@ -18,11 +18,14 @@
18
18
  columns = table_data[:columns] || []
19
19
  data_rows = table_data[:data] || []
20
20
  end
21
-
21
+
22
22
  colspan = columns.length
23
+ has_truncation = columns.any? { |col| col[:cell_class]&.include?('truncate-cell') }
24
+ table_classes = ['table', 'mbs-4']
25
+ table_classes << 'table-fixed' if has_truncation
23
26
  %>
24
27
 
25
- <table class="table mbs-4">
28
+ <table class="<%= table_classes.join(' ') %>">
26
29
  <thead>
27
30
  <tr>
28
31
  <% columns.each do |column| %>
@@ -47,4 +50,4 @@
47
50
  </tr>
48
51
  <% end %>
49
52
  </tbody>
50
- </table>
53
+ </table>
@@ -1,4 +1,4 @@
1
- <div class="flex justify-end mb-1">
1
+ <div class="flex justify-end">
2
2
  <%= render 'rails_pulse/components/active_filters' %>
3
3
  </div>
4
4
 
@@ -0,0 +1,78 @@
1
+ <%= render 'rails_pulse/components/panel', { title: 'Event Sequence ' } do %>
2
+ <% if operations.any? %>
3
+ <table class="table operations-table">
4
+ <thead>
5
+ <tr>
6
+ <th class="w-8"></th>
7
+ <th class="operations-label-cell">Operation</th>
8
+ <th class="operations-duration-cell">Duration</th>
9
+ <th class="w-20">Impact</th>
10
+ <th class="w-16"></th>
11
+ <th class="operations-event-cell">Timeline</th>
12
+ <th class="w-16">Actions</th>
13
+ </tr>
14
+ </thead>
15
+ <tbody data-controller="rails-pulse--expandable-rows" data-action="click->rails-pulse--expandable-rows#toggle">
16
+ <% total_run_duration = @run.duration.to_f %>
17
+ <% @operation_timeline.bars.each_with_index do |bar, index| %>
18
+ <tr data-operation-id="<%= bar.operation.id %>">
19
+ <!-- Toggle chevron column -->
20
+ <td class="text-center cursor-pointer">
21
+ <div class="chevron mbs-2">
22
+ <%= rails_pulse_icon('chevron-right', width: '16', class: 'transition-transform duration-200') %>
23
+ </div>
24
+ </td>
25
+
26
+ <td class="operations-label-cell">
27
+ <span class="text-link" style="color: <%= event_color(bar.operation.operation_type) %>;">
28
+ <%= html_escape(bar.operation.label) %>
29
+ </span>
30
+ </td>
31
+
32
+ <td class="whitespace-nowrap">
33
+ <%= bar.duration.round(2) %> ms
34
+ </td>
35
+
36
+ <td class="whitespace-nowrap">
37
+ <% impact_percentage = total_run_duration > 0 ? (bar.operation.duration / total_run_duration * 100).round(1) : 0 %>
38
+ <%= impact_percentage %>%
39
+ </td>
40
+
41
+ <td class="whitespace-nowrap text-center">
42
+ <%= operation_status_indicator(bar.operation) %>
43
+ </td>
44
+
45
+ <td class="operations-event-cell">
46
+ <div
47
+ class="operations-event"
48
+ style="left: <%= bar.left_pct %>%; width: <%= bar.width_pct %>%; background-color: <%= event_color(bar.operation.operation_type) %>;">
49
+ </div>
50
+ </td>
51
+
52
+ <td>
53
+ <%= link_to rails_pulse_icon('eye', width: '16', class: 'inline-block mbi-2'), operation_path(bar.operation), title: 'View details', data: { turbo_frame: "_top" } %>
54
+ <% if bar.operation.operation_type == "sql" && bar.operation.query.present? %>
55
+ <%= link_to rails_pulse_icon('database', width: '16', class: 'inline-block'), query_path(bar.operation.query), title: 'View query', data: { turbo_frame: "_top" } %>
56
+ <% end %>
57
+ </td>
58
+ </tr>
59
+
60
+ <!-- Expandable details row -->
61
+ <tr
62
+ class="operation-details-row hidden"
63
+ data-operation-id="<%= bar.operation.id %>"
64
+ >
65
+ <td colspan="7" class="pi-8 pb-4">
66
+ <%= turbo_frame_tag "operation_#{bar.operation.id}_details", data: { "operation-url": operation_path(bar.operation) } do %>
67
+ <% end %>
68
+ </td>
69
+ </tr>
70
+ <% end %>
71
+ </tbody>
72
+ </table>
73
+ <% else %>
74
+ <%= render 'rails_pulse/components/empty_state',
75
+ title: 'No operations found for this job run.',
76
+ description: 'This job run may not have had any tracked operations.' %>
77
+ <% end %>
78
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <%= turbo_frame_tag :index_table do %>
2
+ <%= render 'rails_pulse/jobs/job_runs_table' %>
3
+ <% end %>