diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 9235504..a37d951 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -84,7 +84,7 @@ jobs:
"8.0.0") GEMFILE=gemfiles/rails_8_0.gemfile ;;
esac
- BUNDLE_GEMFILE=$GEMFILE bundle exec rails test:all
+ BUNDLE_GEMFILE=$GEMFILE bundle exec rake test:all
lint:
runs-on: ubuntu-latest
diff --git a/.gitignore b/.gitignore
index ad407e8..1a01c0f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,8 @@
/test/dummy/rails_pulse_test*
/test/dummy/db/schema.rb
test/dummy/rails_pulse_development
+test/dummy/rails_pulse_development-shm
+test/dummy/rails_pulse_development-wal
# Environment variables
.env
diff --git a/.rubocop.yml b/.rubocop.yml
index eb227b2..e76a176 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -1,16 +1,24 @@
# Omakase Ruby styling for Rails
inherit_gem: { rubocop-rails-omakase: rubocop.yml }
+plugins: rubocop-minitest
+
# Suppress deprecation warnings for rubocop-rails-omakase
AllCops:
+ NewCops: enable
SuggestExtensions: false
Exclude:
- 'test/dummy/db/schema.rb'
- 'test/dummy/db/rails_pulse_schema.rb'
- 'lib/generators/rails_pulse/templates/migrations/*.rb'
-# Overwrite or add rules to create your own house style
-#
-# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
-# Layout/SpaceInsideArrayLiteralBrackets:
-# Enabled: false
+# Ignore Minitest style preferences in test files
+Minitest/NonPublicTestMethod:
+ Enabled: false
+
+Minitest/MultipleAssertions:
+ Enabled: false
+
+# Disable enforcement of underscore test method names to allow string test names
+Minitest/TestMethodName:
+ Enabled: false
diff --git a/Appraisals b/Appraisals
index 88f8b77..dfc90b8 100644
--- a/Appraisals
+++ b/Appraisals
@@ -1,11 +1,7 @@
-# Appraisals for testing multiple Rails versions
-
appraise "rails-7-2" do
gem "rails", "~> 7.2.0"
- gem "mysql2" # For CI database testing
end
appraise "rails-8-0" do
gem "rails", "~> 8.0.0"
- gem "mysql2" # For CI database testing
-end
+end
\ No newline at end of file
diff --git a/Gemfile b/Gemfile
index 2a9c245..cd697a9 100644
--- a/Gemfile
+++ b/Gemfile
@@ -18,6 +18,7 @@ end
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
gem "rubocop-rails-omakase", require: false
+gem "rubocop-minitest", require: false
gem "css-zero"
gem "groupdate", ">= 6.5.1"
diff --git a/Gemfile.lock b/Gemfile.lock
index abe0196..d1f0b9c 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -306,6 +306,10 @@ GEM
rubocop-ast (1.46.0)
parser (>= 3.3.7.2)
prism (~> 1.4)
+ rubocop-minitest (0.38.2)
+ lint_roller (~> 1.1)
+ rubocop (>= 1.75.0, < 2.0)
+ rubocop-ast (>= 1.38.0, < 2.0)
rubocop-performance (1.25.0)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
@@ -398,6 +402,7 @@ DEPENDENCIES
rails_pulse!
ransack
request_store
+ rubocop-minitest
rubocop-rails-omakase
selenium-webdriver
shoulda-matchers
diff --git a/README.md b/README.md
index 1264317..4a4c459 100644
--- a/README.md
+++ b/README.md
@@ -90,10 +90,10 @@ Generate the installation files:
rails generate rails_pulse:install
```
-Load the database schema:
+Run the database migration:
```bash
-rails db:prepare
+rails db:migrate
```
Add the Rails Pulse route to your application:
@@ -358,15 +358,17 @@ production:
### Schema Loading
-After installation, load the Rails Pulse database schema:
+After installation, run the database migration:
```bash
-rails db:prepare
+rails db:migrate
```
This command works for both:
-- Shared database setup (default): Loads tables into your main application database
-- Separate database setup: Automatically loads tables into your configured Rails Pulse database
+- Shared database setup (default): Creates tables in your main application database
+- Separate database setup: Automatically creates tables in your configured Rails Pulse database
+
+The schema file `db/rails_pulse_schema.rb` serves as your single source of truth for the database structure. Future Rails Pulse updates will provide additional migrations in the `db/rails_pulse_migrate/` directory.
## Testing
diff --git a/Rakefile b/Rakefile
index 985babd..7e6e5d2 100644
--- a/Rakefile
+++ b/Rakefile
@@ -1,109 +1,95 @@
require "bundler/setup"
+require "bundler/gem_tasks"
# Load environment variables from .env file
require "dotenv/load" if File.exist?(".env")
APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
load "rails/tasks/engine.rake"
-load "rails/tasks/statistics.rake"
-
-require "bundler/gem_tasks"
-
-# Test tasks
-namespace :test do
- desc "Run unit tests (models, helpers, services, instrumentation)"
- task :unit do
- sh "rails test test/models test/helpers test/services test/support test/instrumentation"
- end
- desc "Run functional tests (controllers)"
- task :functional do
- sh "rails test test/controllers"
+desc "Run test suite"
+task :test do
+ database = ENV['DB'] || 'sqlite3'
+
+ # Get Rails version from Gemfile.lock or fallback
+ rails_version = begin
+ require 'rails'
+ Rails.version
+ rescue LoadError
+ # Try to get from Gemfile.lock
+ gemfile_lock = File.read('Gemfile.lock') rescue nil
+ if gemfile_lock && gemfile_lock.match(/rails \(([^)]+)\)/)
+ $1
+ else
+ 'unknown'
+ end
end
- desc "Run integration tests"
- task :integration do
- sh "rails test test/integration test/system"
- end
+ puts "\n" + "=" * 50
+ puts "๐ Rails Pulse Test Suite"
+ puts "=" * 50
+ puts "Database: #{database.upcase}"
+ puts "Rails: #{rails_version}"
+ puts "=" * 50
+ puts
- desc "Run all tests"
- task :all do
- sh "rails test"
- end
+ sh "rails test test/controllers test/helpers test/instrumentation test/models test/services"
+end
- desc "Run tests across all database and Rails version combinations (local only - CI uses sqlite3 + postgresql)"
- task :matrix do
- databases = [ "sqlite3", "postgresql", "mysql2" ]
- rails_versions = [ "rails-7-2", "rails-8-0" ]
-
- failed_combinations = []
-
- databases.each do |database|
- rails_versions.each do |rails_version|
- puts "\n" + "=" * 80
- puts "๐งช Local Testing: #{database.upcase} + #{rails_version.upcase}"
- puts "(CI only tests SQLite3 + PostgreSQL for reliability)"
- puts "=" * 80
-
- begin
- gemfile = "gemfiles/#{rails_version.gsub('-', '_')}.gemfile"
-
- # Set environment variables
- env_vars = {
- "DB" => database,
- "BUNDLE_GEMFILE" => gemfile,
- "FORCE_DB_CONFIG" => "true"
- }
-
- # Add database-specific environment variables
- case database
- when "postgresql"
- env_vars.merge!({
- "POSTGRES_USERNAME" => ENV.fetch("POSTGRES_USERNAME", "postgres"),
- "POSTGRES_PASSWORD" => ENV.fetch("POSTGRES_PASSWORD", ""),
- "POSTGRES_HOST" => ENV.fetch("POSTGRES_HOST", "localhost"),
- "POSTGRES_PORT" => ENV.fetch("POSTGRES_PORT", "5432")
- })
- when "mysql2"
- env_vars.merge!({
- "MYSQL_USERNAME" => ENV.fetch("MYSQL_USERNAME", "root"),
- "MYSQL_PASSWORD" => ENV.fetch("MYSQL_PASSWORD", "password"),
- "MYSQL_HOST" => ENV.fetch("MYSQL_HOST", "localhost"),
- "MYSQL_PORT" => ENV.fetch("MYSQL_PORT", "3306")
- })
- end
-
- # Build environment string
- env_string = env_vars.map { |k, v| "#{k}=#{v}" }.join(" ")
-
- # Run the test command
- sh "#{env_string} bundle exec rails test:all"
-
- puts "โ
PASSED: #{database} + #{rails_version}"
-
- rescue => e
- puts "โ FAILED: #{database} + #{rails_version}"
- puts "Error: #{e.message}"
- failed_combinations << "#{database} + #{rails_version}"
+desc "Test all database and Rails version combinations"
+task :test_matrix do
+ databases = %w[sqlite3 postgresql mysql2]
+ rails_versions = %w[rails-7-2 rails-8-0]
+
+ failed_combinations = []
+ total_combinations = databases.size * rails_versions.size
+ current = 0
+
+ puts "\n" + "=" * 60
+ puts "๐ Rails Pulse Full Test Matrix"
+ puts "=" * 60
+ puts "Testing #{total_combinations} combinations..."
+ puts "=" * 60
+
+ databases.each do |database|
+ rails_versions.each do |rails_version|
+ current += 1
+
+ puts "\n[#{current}/#{total_combinations}] Testing: #{database.upcase} + #{rails_version.upcase.gsub('-', ' ')}"
+ puts "-" * 50
+
+ begin
+ if rails_version == "rails-8-0" && database == "sqlite3"
+ # Current default setup
+ sh "bundle exec rake test"
+ else
+ # Use appraisal with specific database
+ sh "DB=#{database} bundle exec appraisal #{rails_version} rake test"
end
- end
- end
- puts "\n" + "=" * 80
- puts "๐ Local Test Matrix Results"
- puts "(CI automatically tests SQLite3 + PostgreSQL only)"
- puts "=" * 80
+ puts "โ
PASSED: #{database} + #{rails_version}"
- if failed_combinations.empty?
- puts "โ
All combinations passed!"
- else
- puts "โ Failed combinations:"
- failed_combinations.each { |combo| puts " - #{combo}" }
- exit 1
+ rescue => e
+ puts "โ FAILED: #{database} + #{rails_version}"
+ puts " Error: #{e.message}"
+ failed_combinations << "#{database} + #{rails_version}"
+ end
end
end
+
+ puts "\n" + "=" * 60
+ puts "๐ Test Matrix Results"
+ puts "=" * 60
+
+ if failed_combinations.empty?
+ puts "๐ All #{total_combinations} combinations passed!"
+ else
+ puts "โ
Passed: #{total_combinations - failed_combinations.size}/#{total_combinations}"
+ puts "โ Failed combinations:"
+ failed_combinations.each { |combo| puts " โข #{combo}" }
+ exit 1
+ end
end
-# Override default test task
-desc "Run all tests"
-task test: "test:all"
+
+task default: :test
diff --git a/app/controllers/rails_pulse/queries_controller.rb b/app/controllers/rails_pulse/queries_controller.rb
index b6ab065..ef54299 100644
--- a/app/controllers/rails_pulse/queries_controller.rb
+++ b/app/controllers/rails_pulse/queries_controller.rb
@@ -55,7 +55,7 @@ def chart_model
end
def table_model
- show_action? ? Operation : Summary
+ Summary
end
def chart_class
@@ -76,7 +76,10 @@ def build_chart_ransack_params(ransack_params)
base_params[:avg_duration_gteq] = @start_duration if @start_duration && @start_duration > 0
if show_action?
- base_params.merge(summarizable_id_eq: @query.id)
+ base_params.merge(
+ summarizable_id_eq: @query.id,
+ summarizable_type_eq: "RailsPulse::Query"
+ )
else
base_params
end
@@ -84,13 +87,14 @@ def build_chart_ransack_params(ransack_params)
def build_table_ransack_params(ransack_params)
if show_action?
- # For Operation model on show page
+ # For Summary model on show page
params = ransack_params.merge(
- occurred_at_gteq: Time.at(@table_start_time),
- occurred_at_lt: Time.at(@table_end_time),
- query_id_eq: @query.id
+ period_start_gteq: Time.at(@table_start_time),
+ period_start_lt: Time.at(@table_end_time),
+ summarizable_id_eq: @query.id,
+ summarizable_type_eq: "RailsPulse::Query"
)
- params[:duration_gteq] = @start_duration if @start_duration && @start_duration > 0
+ params[:avg_duration_gteq] = @start_duration if @start_duration && @start_duration > 0
params
else
# For Summary model on index page
@@ -104,23 +108,13 @@ def build_table_ransack_params(ransack_params)
end
def default_table_sort
- show_action? ? "occurred_at desc" : "period_start desc"
+ "period_start desc"
end
def build_table_results
if show_action?
- # Only show operations that belong to time periods where we have query summaries
- # This ensures the table data is consistent with the chart data
- @ransack_query.result
- .joins(<<~SQL)
- INNER JOIN rails_pulse_summaries ON
- rails_pulse_summaries.summarizable_id = rails_pulse_operations.query_id AND
- rails_pulse_summaries.summarizable_type = 'RailsPulse::Query' AND
- rails_pulse_summaries.period_type = '#{period_type}' AND
- rails_pulse_operations.occurred_at >= rails_pulse_summaries.period_start AND
- rails_pulse_operations.occurred_at < rails_pulse_summaries.period_end
- SQL
- .distinct
+ # For Summary model on show page - ransack params already include query ID and type filters
+ @ransack_query.result.where(period_type: period_type)
else
Queries::Tables::Index.new(
ransack_query: @ransack_query,
diff --git a/app/controllers/rails_pulse/requests_controller.rb b/app/controllers/rails_pulse/requests_controller.rb
index b93627b..53cdc68 100644
--- a/app/controllers/rails_pulse/requests_controller.rb
+++ b/app/controllers/rails_pulse/requests_controller.rb
@@ -5,13 +5,7 @@ class RequestsController < ApplicationController
before_action :set_request, only: :show
def index
- unless turbo_frame_request?
- @average_response_times_metric_card = RailsPulse::Routes::Cards::AverageResponseTimes.new(route: nil).to_metric_card
- @percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(route: nil).to_metric_card
- @request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(route: nil).to_metric_card
- @error_rate_per_route_metric_card = RailsPulse::Routes::Cards::ErrorRatePerRoute.new(route: nil).to_metric_card
- end
-
+ setup_metric_cards
setup_chart_and_table_data
end
@@ -21,12 +15,22 @@ def show
private
+ def setup_metric_cards
+ return if turbo_frame_request?
+
+ @average_response_times_metric_card = RailsPulse::Routes::Cards::AverageResponseTimes.new(route: nil).to_metric_card
+ @percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(route: nil).to_metric_card
+ @request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(route: nil).to_metric_card
+ @error_rate_per_route_metric_card = RailsPulse::Routes::Cards::ErrorRatePerRoute.new(route: nil).to_metric_card
+ end
+
+
def chart_model
- Summary
+ RailsPulse::Summary
end
def table_model
- Request
+ RailsPulse::Request
end
def chart_class
@@ -64,27 +68,36 @@ def default_table_sort
end
def build_table_results
- # Only show requests that belong to time periods where we have overall request summaries
- # This ensures the table data is consistent with the chart data
- @ransack_query.result
- .joins(:route)
- .joins(<<~SQL)
- INNER JOIN rails_pulse_summaries ON
- rails_pulse_summaries.summarizable_id = 0 AND
- rails_pulse_summaries.summarizable_type = 'RailsPulse::Request' AND
- rails_pulse_summaries.period_type = '#{period_type}' AND
- rails_pulse_requests.occurred_at >= rails_pulse_summaries.period_start AND
- rails_pulse_requests.occurred_at < rails_pulse_summaries.period_end
- SQL
- .select(
- "rails_pulse_requests.id",
- "rails_pulse_requests.occurred_at",
- "rails_pulse_requests.duration",
- "rails_pulse_requests.status",
- "rails_pulse_requests.route_id",
- "rails_pulse_routes.path"
- )
- .distinct
+ base_query = @ransack_query.result.includes(:route)
+
+ # If sorting by route_path, we need to join the routes table
+ if @ransack_query.sorts.any? { |sort| sort.name == "route_path" }
+ base_query = base_query.joins(:route)
+ end
+
+ base_query
+ end
+
+
+ def setup_table_data(ransack_params)
+ table_ransack_params = build_table_ransack_params(ransack_params)
+ @ransack_query = table_model.ransack(table_ransack_params)
+
+ # Only apply default sort if not using Requests::Tables::Index (which handles its own sorting)
+ # For requests, we always use the Tables::Index on the index action
+ unless action_name == "index"
+ @ransack_query.sorts = default_table_sort if @ransack_query.sorts.empty?
+ end
+
+ table_results = build_table_results
+ handle_pagination
+
+ @pagy, @table_data = pagy(table_results, limit: session_pagination_limit)
+ end
+
+ def handle_pagination
+ method = pagination_method
+ send(method, params[:limit]) if params[:limit].present?
end
def set_request
diff --git a/app/helpers/rails_pulse/breadcrumbs_helper.rb b/app/helpers/rails_pulse/breadcrumbs_helper.rb
index b905320..c60ceef 100644
--- a/app/helpers/rails_pulse/breadcrumbs_helper.rb
+++ b/app/helpers/rails_pulse/breadcrumbs_helper.rb
@@ -25,7 +25,7 @@ def breadcrumbs
return crumbs if path_segments.empty?
- current_path = "/rails_pulse"
+ current_path = main_app.rails_pulse_path.chomp("/")
path_segments.each_with_index do |segment, index|
current_path += "/#{segment}"
diff --git a/app/helpers/rails_pulse/chart_helper.rb b/app/helpers/rails_pulse/chart_helper.rb
index 71899f8..ff8eeac 100644
--- a/app/helpers/rails_pulse/chart_helper.rb
+++ b/app/helpers/rails_pulse/chart_helper.rb
@@ -27,7 +27,7 @@ def base_chart_options(units: nil, zoom: false)
top: "10%",
containLabel: true
},
- animation: false
+ animation: true
}
end
diff --git a/app/helpers/rails_pulse/formatting_helper.rb b/app/helpers/rails_pulse/formatting_helper.rb
index da19fbe..a0c8bdd 100644
--- a/app/helpers/rails_pulse/formatting_helper.rb
+++ b/app/helpers/rails_pulse/formatting_helper.rb
@@ -3,7 +3,8 @@ module FormattingHelper
def human_readable_occurred_at(occurred_at)
return "" unless occurred_at.present?
time = occurred_at.is_a?(String) ? Time.parse(occurred_at) : occurred_at
- time.strftime("%b %d, %Y %l:%M %p")
+ # Convert to local system timezone (same as charts use)
+ time.getlocal.strftime("%b %d, %Y %l:%M %p")
end
def time_ago_in_words(time)
@@ -11,8 +12,10 @@ def time_ago_in_words(time)
# Convert to Time object if it's a string
time = Time.parse(time.to_s) if time.is_a?(String)
+ # Convert to local system timezone for consistent calculation
+ time = time.getlocal
- seconds_ago = Time.current - time
+ seconds_ago = Time.now - time
case seconds_ago
when 0..59
@@ -25,5 +28,21 @@ def time_ago_in_words(time)
"#{(seconds_ago / 86400).to_i}d ago"
end
end
+
+ def human_readable_summary_period(summary)
+ return "" unless summary&.period_start&.present? && summary&.period_end&.present?
+
+ # Convert UTC times to local system timezone to match chart display
+ start_time = summary.period_start.getlocal
+ end_time = summary.period_end.getlocal
+
+
+ case summary.period_type
+ when "hour"
+ start_time.strftime("%b %e %Y, %l:%M %p") + " - " + end_time.strftime("%l:%M %p")
+ when "day"
+ start_time.strftime("%b %e, %Y")
+ end
+ end
end
end
diff --git a/app/models/rails_pulse/dashboard/tables/slow_queries.rb b/app/models/rails_pulse/dashboard/tables/slow_queries.rb
index a96fed6..099e99f 100644
--- a/app/models/rails_pulse/dashboard/tables/slow_queries.rb
+++ b/app/models/rails_pulse/dashboard/tables/slow_queries.rb
@@ -32,7 +32,7 @@ def to_table_data
{
query_text: truncate_query(record.normalized_sql),
query_id: record.query_id,
- query_link: "/rails_pulse/queries/#{record.query_id}",
+ query_link: RailsPulse::Engine.routes.url_helpers.query_path(record.query_id),
average_time: record.avg_duration.to_f.round(0),
request_count: record.request_count,
last_request: time_ago_in_words(record.last_seen)
diff --git a/app/models/rails_pulse/dashboard/tables/slow_routes.rb b/app/models/rails_pulse/dashboard/tables/slow_routes.rb
index cf07279..ad0c5f7 100644
--- a/app/models/rails_pulse/dashboard/tables/slow_routes.rb
+++ b/app/models/rails_pulse/dashboard/tables/slow_routes.rb
@@ -33,7 +33,7 @@ def to_table_data
{
route_path: record.path,
route_id: record.route_id,
- route_link: "/rails_pulse/routes/#{record.route_id}",
+ route_link: RailsPulse::Engine.routes.url_helpers.route_path(record.route_id),
average_time: record.avg_duration.to_f.round(0),
request_count: record.request_count,
last_request: time_ago_in_words(record.last_seen)
diff --git a/app/models/rails_pulse/queries/cards/average_query_times.rb b/app/models/rails_pulse/queries/cards/average_query_times.rb
index 46106ca..0434b46 100644
--- a/app/models/rails_pulse/queries/cards/average_query_times.rb
+++ b/app/models/rails_pulse/queries/cards/average_query_times.rb
@@ -64,7 +64,7 @@ def to_metric_card
context: "queries",
title: "Average Query Time",
summary: "#{average_query_time} ms",
- line_chart_data: sparkline_data,
+ chart_data: sparkline_data,
trend_icon: trend_icon,
trend_amount: trend_amount,
trend_text: "Compared to last week"
diff --git a/app/models/rails_pulse/queries/cards/execution_rate.rb b/app/models/rails_pulse/queries/cards/execution_rate.rb
index ce75d60..f032bf6 100644
--- a/app/models/rails_pulse/queries/cards/execution_rate.rb
+++ b/app/models/rails_pulse/queries/cards/execution_rate.rb
@@ -10,10 +10,20 @@ def to_metric_card
last_7_days = 7.days.ago.beginning_of_day
previous_7_days = 14.days.ago.beginning_of_day
+ # Get the most common period type for this query, or fall back to "day"
+ period_type = if @query
+ RailsPulse::Summary.where(
+ summarizable_type: "RailsPulse::Query",
+ summarizable_id: @query.id
+ ).group(:period_type).count.max_by(&:last)&.first || "day"
+ else
+ "day"
+ end
+
# Single query to get all count metrics with conditional aggregation
base_query = RailsPulse::Summary.where(
summarizable_type: "RailsPulse::Query",
- period_type: "day",
+ period_type: period_type,
period_start: 2.weeks.ago.beginning_of_day..Time.current
)
base_query = base_query.where(summarizable_id: @query.id) if @query
@@ -33,31 +43,60 @@ def to_metric_card
trend_icon = percentage < 0.1 ? "move-right" : current_period_count < previous_period_count ? "trending-down" : "trending-up"
trend_amount = previous_period_count.zero? ? "0%" : "#{percentage}%"
- # Sparkline data by day with zero-filled days over the last 14 days
- grouped_daily = base_query
- .group_by_day(:period_start, time_zone: "UTC")
- .sum(:count)
+ # Sparkline data with zero-filled periods over the last 14 days
+ if period_type == "day"
+ grouped_data = base_query
+ .group_by_day(:period_start, time_zone: "UTC")
+ .sum(:count)
+
+ start_period = 2.weeks.ago.beginning_of_day.to_date
+ end_period = Time.current.to_date
- start_day = 2.weeks.ago.beginning_of_day.to_date
- end_day = Time.current.to_date
+ sparkline_data = {}
+ (start_period..end_period).each do |day|
+ total = grouped_data[day] || 0
+ label = day.strftime("%b %-d")
+ sparkline_data[label] = { value: total }
+ end
+ else
+ # For hourly data, group by day for sparkline display
+ grouped_data = base_query
+ .group("DATE(period_start)")
+ .sum(:count)
- sparkline_data = {}
- (start_day..end_day).each do |day|
- total = grouped_daily[day] || 0
- label = day.strftime("%b %-d")
- sparkline_data[label] = { value: total }
+ start_period = 2.weeks.ago.beginning_of_day.to_date
+ end_period = Time.current.to_date
+
+ sparkline_data = {}
+ (start_period..end_period).each do |day|
+ date_key = day.strftime("%Y-%m-%d")
+ total = grouped_data[date_key] || 0
+ label = day.strftime("%b %-d")
+ sparkline_data[label] = { value: total }
+ end
end
- # Calculate average executions per minute over 2-week period
- total_minutes = 2.weeks / 1.minute
- average_executions_per_minute = total_execution_count / total_minutes
+ # Calculate appropriate rate display based on frequency
+ total_minutes = 2.weeks / 1.minute.to_f
+ executions_per_minute = total_execution_count.to_f / total_minutes
+
+ # Choose appropriate time unit for display
+ if executions_per_minute >= 1
+ summary = "#{executions_per_minute.round(2)} / min"
+ elsif executions_per_minute * 60 >= 1
+ executions_per_hour = executions_per_minute * 60
+ summary = "#{executions_per_hour.round(2)} / hour"
+ else
+ executions_per_day = executions_per_minute * 60 * 24
+ summary = "#{executions_per_day.round(2)} / day"
+ end
{
id: "execution_rate",
context: "queries",
title: "Execution Rate",
- summary: "#{average_executions_per_minute.round(2)} / min",
- line_chart_data: sparkline_data,
+ summary: summary,
+ chart_data: sparkline_data,
trend_icon: trend_icon,
trend_amount: trend_amount,
trend_text: "Compared to last week"
diff --git a/app/models/rails_pulse/queries/cards/percentile_query_times.rb b/app/models/rails_pulse/queries/cards/percentile_query_times.rb
index 4284b31..3ea4680 100644
--- a/app/models/rails_pulse/queries/cards/percentile_query_times.rb
+++ b/app/models/rails_pulse/queries/cards/percentile_query_times.rb
@@ -53,7 +53,7 @@ def to_metric_card
context: "queries",
title: "95th Percentile Query Time",
summary: "#{p95_query_time} ms",
- line_chart_data: sparkline_data,
+ chart_data: sparkline_data,
trend_icon: trend_icon,
trend_amount: trend_amount,
trend_text: "Compared to last week"
diff --git a/app/models/rails_pulse/queries/charts/average_query_times.rb b/app/models/rails_pulse/queries/charts/average_query_times.rb
index 55cdd19..11024b1 100644
--- a/app/models/rails_pulse/queries/charts/average_query_times.rb
+++ b/app/models/rails_pulse/queries/charts/average_query_times.rb
@@ -12,13 +12,9 @@ def initialize(ransack_query:, period_type: nil, query: nil, start_time: nil, en
end
def to_rails_chart
- summaries = @ransack_query.result(distinct: false).where(
- summarizable_type: "RailsPulse::Query",
- period_type: @period_type
- )
-
- summaries = summaries.where(summarizable_id: @query.id) if @query
- summaries = summaries
+ # The ransack query already contains the correct filters, just add period_type
+ summaries = @ransack_query.result(distinct: false)
+ .where(period_type: @period_type)
.group(:period_start)
.having("AVG(avg_duration) > ?", @start_duration || 0)
.average(:avg_duration)
diff --git a/app/models/rails_pulse/request.rb b/app/models/rails_pulse/request.rb
index a795c1a..9e4e437 100644
--- a/app/models/rails_pulse/request.rb
+++ b/app/models/rails_pulse/request.rb
@@ -52,7 +52,7 @@ def self.ransackable_associations(auth_object = nil)
end
def to_s
- occurred_at.strftime("%b %d, %Y %l:%M %p")
+ occurred_at.getlocal.strftime("%b %d, %Y %l:%M %p")
end
private
diff --git a/app/models/rails_pulse/requests/charts/average_response_times.rb b/app/models/rails_pulse/requests/charts/average_response_times.rb
index c1dbfa1..7b419d4 100644
--- a/app/models/rails_pulse/requests/charts/average_response_times.rb
+++ b/app/models/rails_pulse/requests/charts/average_response_times.rb
@@ -13,11 +13,11 @@ def initialize(ransack_query:, period_type: nil, route: nil, start_time: nil, en
def to_rails_chart
summaries = @ransack_query.result(distinct: false).where(
- summarizable_type: "RailsPulse::Route",
+ summarizable_type: "RailsPulse::Request",
+ summarizable_id: 0, # Overall request summaries
period_type: @period_type
)
- summaries = summaries.where(summarizable_id: @route.id) if @route
summaries = summaries
.group(:period_start)
.having("AVG(avg_duration) > ?", @start_duration || 0)
diff --git a/app/models/rails_pulse/requests/tables/index.rb b/app/models/rails_pulse/requests/tables/index.rb
new file mode 100644
index 0000000..034c78e
--- /dev/null
+++ b/app/models/rails_pulse/requests/tables/index.rb
@@ -0,0 +1,77 @@
+module RailsPulse
+ module Requests
+ module Tables
+ class Index
+ def initialize(ransack_query:, period_type: nil, start_time:, params:)
+ @ransack_query = ransack_query
+ @period_type = period_type
+ @start_time = start_time
+ @params = params
+ end
+
+ def to_table
+ # Check if we have explicit ransack sorts
+ has_sorts = @ransack_query.sorts.any?
+
+ base_query = @ransack_query.result(distinct: false)
+ .where(
+ summarizable_type: "RailsPulse::Request",
+ summarizable_id: 0, # Overall request summaries
+ period_type: @period_type
+ )
+
+ # Apply grouping and aggregation for time periods
+ grouped_query = base_query
+ .group(
+ "rails_pulse_summaries.period_start",
+ "rails_pulse_summaries.period_end",
+ "rails_pulse_summaries.period_type"
+ )
+ .select(
+ "rails_pulse_summaries.period_start",
+ "rails_pulse_summaries.period_end",
+ "rails_pulse_summaries.period_type",
+ "AVG(rails_pulse_summaries.avg_duration) as avg_duration",
+ "MAX(rails_pulse_summaries.max_duration) as max_duration",
+ "MIN(rails_pulse_summaries.min_duration) as min_duration",
+ "SUM(rails_pulse_summaries.count) as count",
+ "SUM(rails_pulse_summaries.error_count) as error_count",
+ "SUM(rails_pulse_summaries.success_count) as success_count"
+ )
+
+ # Apply sorting based on ransack sorts or use default
+ if has_sorts
+ # Apply custom sorting based on ransack parameters
+ sort = @ransack_query.sorts.first
+ direction = sort.dir == "desc" ? :desc : :asc
+
+ case sort.name
+ when "avg_duration"
+ grouped_query = grouped_query.order(Arel.sql("AVG(rails_pulse_summaries.avg_duration)").send(direction))
+ when "max_duration"
+ grouped_query = grouped_query.order(Arel.sql("MAX(rails_pulse_summaries.max_duration)").send(direction))
+ when "min_duration"
+ grouped_query = grouped_query.order(Arel.sql("MIN(rails_pulse_summaries.min_duration)").send(direction))
+ when "count"
+ grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count)").send(direction))
+ when "requests_per_minute"
+ grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count) / 60.0").send(direction))
+ when "error_rate_percentage"
+ grouped_query = grouped_query.order(Arel.sql("(SUM(rails_pulse_summaries.error_count) * 100.0) / SUM(rails_pulse_summaries.count)").send(direction))
+ when "period_start"
+ grouped_query = grouped_query.order(period_start: direction)
+ else
+ # Unknown sort field, fallback to default
+ grouped_query = grouped_query.order(period_start: :desc)
+ end
+ else
+ # Apply default sort when no explicit sort is provided (matches controller default_table_sort)
+ grouped_query = grouped_query.order(period_start: :desc)
+ end
+
+ grouped_query
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/rails_pulse/routes/cards/average_response_times.rb b/app/models/rails_pulse/routes/cards/average_response_times.rb
index 97e2e68..89b5e43 100644
--- a/app/models/rails_pulse/routes/cards/average_response_times.rb
+++ b/app/models/rails_pulse/routes/cards/average_response_times.rb
@@ -62,7 +62,7 @@ def to_metric_card
context: "routes",
title: "Average Response Time",
summary: "#{average_response_time} ms",
- line_chart_data: sparkline_data,
+ chart_data: sparkline_data,
trend_icon: trend_icon,
trend_amount: trend_amount,
trend_text: "Compared to last week"
diff --git a/app/models/rails_pulse/routes/cards/error_rate_per_route.rb b/app/models/rails_pulse/routes/cards/error_rate_per_route.rb
index 0fbca75..ea920ae 100644
--- a/app/models/rails_pulse/routes/cards/error_rate_per_route.rb
+++ b/app/models/rails_pulse/routes/cards/error_rate_per_route.rb
@@ -59,7 +59,7 @@ def to_metric_card
context: "routes",
title: "Error Rate Per Route",
summary: "#{overall_error_rate}%",
- line_chart_data: sparkline_data,
+ chart_data: sparkline_data,
trend_icon: trend_icon,
trend_amount: trend_amount,
trend_text: "Compared to last week"
diff --git a/app/models/rails_pulse/routes/cards/percentile_response_times.rb b/app/models/rails_pulse/routes/cards/percentile_response_times.rb
index 6d283f6..26467b2 100644
--- a/app/models/rails_pulse/routes/cards/percentile_response_times.rb
+++ b/app/models/rails_pulse/routes/cards/percentile_response_times.rb
@@ -53,7 +53,7 @@ def to_metric_card
context: "routes",
title: "95th Percentile Response Time",
summary: "#{p95_response_time} ms",
- line_chart_data: sparkline_data,
+ chart_data: sparkline_data,
trend_icon: trend_icon,
trend_amount: trend_amount,
trend_text: "Compared to last week"
diff --git a/app/models/rails_pulse/routes/cards/request_count_totals.rb b/app/models/rails_pulse/routes/cards/request_count_totals.rb
index 0ab86ed..96c9b1c 100644
--- a/app/models/rails_pulse/routes/cards/request_count_totals.rb
+++ b/app/models/rails_pulse/routes/cards/request_count_totals.rb
@@ -48,16 +48,27 @@ def to_metric_card
sparkline_data[label] = { value: total }
end
- # Calculate average requests per minute over 2-week period
- total_minutes = 2.weeks / 1.minute
- average_requests_per_minute = total_request_count / total_minutes
+ # Calculate appropriate rate display based on frequency
+ total_minutes = 2.weeks / 1.minute.to_f
+ requests_per_minute = total_request_count.to_f / total_minutes
+
+ # Choose appropriate time unit for display
+ if requests_per_minute >= 1
+ summary = "#{requests_per_minute.round(2)} / min"
+ elsif requests_per_minute * 60 >= 1
+ requests_per_hour = requests_per_minute * 60
+ summary = "#{requests_per_hour.round(2)} / hour"
+ else
+ requests_per_day = requests_per_minute * 60 * 24
+ summary = "#{requests_per_day.round(2)} / day"
+ end
{
id: "request_count_totals",
context: "routes",
title: "Request Count Total",
- summary: "#{average_requests_per_minute.round(2)} / min",
- line_chart_data: sparkline_data,
+ summary: summary,
+ chart_data: sparkline_data,
trend_icon: trend_icon,
trend_amount: trend_amount,
trend_text: "Compared to last week"
diff --git a/app/models/rails_pulse/routes/tables/index.rb b/app/models/rails_pulse/routes/tables/index.rb
index 7a264be..1d582e6 100644
--- a/app/models/rails_pulse/routes/tables/index.rb
+++ b/app/models/rails_pulse/routes/tables/index.rb
@@ -13,7 +13,9 @@ def to_table
# Check if we have explicit ransack sorts
has_sorts = @ransack_query.sorts.any?
- base_query = @ransack_query.result(distinct: false)
+ # Store sorts for later and get result without ordering
+ # This prevents PostgreSQL GROUP BY issues with ORDER BY columns
+ base_query = @ransack_query.result(distinct: false).reorder(nil)
.joins("INNER JOIN rails_pulse_routes ON rails_pulse_routes.id = rails_pulse_summaries.summarizable_id")
.where(
summarizable_type: "RailsPulse::Route",
@@ -39,7 +41,7 @@ def to_table
"rails_pulse_routes.method as route_method",
"AVG(rails_pulse_summaries.avg_duration) as avg_duration",
"MAX(rails_pulse_summaries.max_duration) as max_duration",
- "SUM(rails_pulse_summaries.count) as count",
+ "SUM(rails_pulse_summaries.count) as request_count",
"SUM(rails_pulse_summaries.error_count) as error_count",
"SUM(rails_pulse_summaries.success_count) as success_count"
)
@@ -55,7 +57,7 @@ def to_table
grouped_query = grouped_query.order(Arel.sql("AVG(rails_pulse_summaries.avg_duration)").send(direction))
when "max_duration_sort"
grouped_query = grouped_query.order(Arel.sql("MAX(rails_pulse_summaries.max_duration)").send(direction))
- when "count_sort"
+ when "count_sort", "request_count_sort"
grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count)").send(direction))
when "requests_per_minute"
grouped_query = grouped_query.order(Arel.sql("SUM(rails_pulse_summaries.count) / 60.0").send(direction))
diff --git a/app/models/rails_pulse/summary.rb b/app/models/rails_pulse/summary.rb
index 6926410..776276e 100644
--- a/app/models/rails_pulse/summary.rb
+++ b/app/models/rails_pulse/summary.rb
@@ -36,9 +36,10 @@ class Summary < RailsPulse::ApplicationRecord
# Ransack configuration
def self.ransackable_attributes(auth_object = nil)
%w[
- period_start period_end avg_duration max_duration count error_count
+ period_start period_end avg_duration min_duration max_duration count error_count
requests_per_minute error_rate_percentage route_path_cont
execution_count total_time_consumed normalized_sql
+ summarizable_id summarizable_type
]
end
@@ -46,17 +47,16 @@ def self.ransackable_associations(auth_object = nil)
%w[route query]
end
- # Custom ransackers for calculated fields (designed to work with GROUP BY queries)
- ransacker :count do
- Arel.sql("SUM(rails_pulse_summaries.count)") # Use SUM for proper grouping
- end
+ # Note: Basic fields like count, avg_duration, min_duration, max_duration
+ # are handled automatically by Ransack using actual database columns
+ # Custom ransackers for calculated fields only
ransacker :requests_per_minute do
- Arel.sql("SUM(rails_pulse_summaries.count) / 60.0") # Use SUM for consistency
+ Arel.sql("rails_pulse_summaries.count / 60.0")
end
ransacker :error_rate_percentage do
- Arel.sql("(SUM(rails_pulse_summaries.error_count) * 100.0) / SUM(rails_pulse_summaries.count)") # Use SUM for both
+ Arel.sql("(rails_pulse_summaries.error_count * 100.0) / rails_pulse_summaries.count")
end
diff --git a/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb b/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb
index d95a53c..bac22a3 100644
--- a/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb
+++ b/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb
@@ -38,9 +38,17 @@ def detect_query_type
def count_tables
tables = []
- tables.concat(sql.scan(/FROM\s+(\w+)/i).flatten)
- tables.concat(sql.scan(/JOIN\s+(\w+)/i).flatten)
- tables.uniq.length
+
+ # Match FROM clause with various table name formats
+ # Handles: table_name, schema.table, "quoted_table", `backtick_table`
+ tables.concat(sql.scan(/FROM\s+(?:`([^`]+)`|"([^"]+)"|'([^']+)'|(\w+(?:\.\w+)?))/i).flatten.compact)
+
+ # Match JOIN clauses (INNER JOIN, LEFT JOIN, etc.)
+ tables.concat(sql.scan(/(?:INNER\s+|LEFT\s+|RIGHT\s+|FULL\s+|CROSS\s+)?JOIN\s+(?:`([^`]+)`|"([^"]+)"|'([^']+)'|(\w+(?:\.\w+)?))/i).flatten.compact)
+
+ # Remove schema prefixes for uniqueness check (schema.table -> table)
+ normalized_tables = tables.map { |table| table.split(".").last }
+ normalized_tables.uniq.length
end
def count_joins
diff --git a/app/views/rails_pulse/components/_metric_card.html.erb b/app/views/rails_pulse/components/_metric_card.html.erb
index 490b873..f5157b7 100644
--- a/app/views/rails_pulse/components/_metric_card.html.erb
+++ b/app/views/rails_pulse/components/_metric_card.html.erb
@@ -4,7 +4,7 @@
context = data[:context]
title = data[:title]
summary = data[:summary]
- line_chart_data = data[:line_chart_data]
+ chart_data = data[:chart_data]
trend_icon = data[:trend_icon]
trend_amount = data[:trend_amount]
trend_text = data[:trend_text]
@@ -38,7 +38,7 @@
}
)
%>
- <%= bar_chart line_chart_data, height: "100%", options: chart_options %>
+ <%= bar_chart chart_data, height: "100%", options: chart_options %>
diff --git a/app/views/rails_pulse/components/_operation_details_popover.html.erb b/app/views/rails_pulse/components/_operation_details_popover.html.erb
index b74c83a..f418b1d 100644
--- a/app/views/rails_pulse/components/_operation_details_popover.html.erb
+++ b/app/views/rails_pulse/components/_operation_details_popover.html.erb
@@ -26,7 +26,7 @@
Occurred At
- <%= operation.occurred_at.strftime("%H:%M:%S.%L") %>
+ <%= operation.occurred_at.getlocal.strftime("%H:%M:%S.%L") %>
<% end %>
diff --git a/app/views/rails_pulse/components/_sparkline_stats.html.erb b/app/views/rails_pulse/components/_sparkline_stats.html.erb
index efbd9a3..ba60abb 100644
--- a/app/views/rails_pulse/components/_sparkline_stats.html.erb
+++ b/app/views/rails_pulse/components/_sparkline_stats.html.erb
@@ -2,7 +2,7 @@
<%= summary %>
- <%= bar_chart line_chart_data, height: "100%", options: sparkline_chart_options %>
+ <%= bar_chart chart_data, height: "100%", options: sparkline_chart_options %>
-inverse p-0">
diff --git a/app/views/rails_pulse/dashboard/index.html.erb b/app/views/rails_pulse/dashboard/index.html.erb
index 3f6244c..084fd58 100644
--- a/app/views/rails_pulse/dashboard/index.html.erb
+++ b/app/views/rails_pulse/dashboard/index.html.erb
@@ -71,7 +71,7 @@
<%= render 'rails_pulse/components/panel', {
title: 'Slowest Queries This Week',
- help_heading: 'Slowest Queries',
+ help_heading: 'Slowest Queries',
help_text: 'This panel shows the slowest database queries in your application this week, including average execution time and when they were last seen.',
actions: [{ url: queries_path, icon: 'external-link', title: 'View details', data: { turbo_frame: '_top' } }],
card_classes: 'table-container'
diff --git a/app/views/rails_pulse/queries/_analysis_results.html.erb b/app/views/rails_pulse/queries/_analysis_results.html.erb
index c774bf7..498b9c4 100644
--- a/app/views/rails_pulse/queries/_analysis_results.html.erb
+++ b/app/views/rails_pulse/queries/_analysis_results.html.erb
@@ -12,7 +12,37 @@
<%= query.query_stats['table_count'] || 0 %>
Joins
<%= query.query_stats['join_count'] || 0 %>
-
Complexity Score
+
+ Complexity Score
+
+
+ <%= rails_pulse_icon 'info', height: "14px" %>
+
+
+
+
+
Complexity Score
+
+ A calculated score representing query complexity based on multiple factors:
+
+
+ - Tables: +2 points per table
+ - Joins: +3 points per join
+ - WHERE conditions: +1 point per condition, +2 per function
+ - UNIONs: +4 points each
+ - Subqueries: +5 points each
+
+
+ Higher scores indicate more complex queries that may need optimization.
+
+
+
+
+
<%= query.query_stats['estimated_complexity'] || 0 %>
Has LIMIT
<%= query.query_stats['has_limit'] ? 'Yes' : 'No' %>
@@ -52,36 +82,36 @@
+
<% if query.issues.present? && query.issues.any? %>
-
-
Issues Detected
-
- <% query.issues.each do |issue| %>
- - <%= issue['description'] %>
- <% end %>
-
-
+
+ Issues Detected
+
+ <% query.issues.each do |issue| %>
+ - <%= issue['description'] %>
+ <% end %>
+
<% end %>
+
<% if query.suggestions.present? && query.suggestions.any? %>
-
-
Optimization Suggestions
-
- <% query.suggestions.each do |suggestion| %>
- -
- <%= suggestion['action'] %>.
- <%= suggestion['benefit'] if suggestion['benefit'].present? %>
-
- <% end %>
-
-
+
+ Optimization Suggestions
+
+ <% query.suggestions.each do |suggestion| %>
+ -
+ <%= suggestion['action'] %>.
+ <%= suggestion['benefit'] if suggestion['benefit'].present? %>
+
+ <% end %>
+
<% end %>
<% if query.explain_plan.present? %>
- <%= render 'rails_pulse/components/panel', title: 'Execution Plan' do %>
- <%= query.explain_plan %>
- <% end %>
+
+ Execution Plan
+ <%= query.explain_plan %>
<% end %>
diff --git a/app/views/rails_pulse/queries/_show_table.html.erb b/app/views/rails_pulse/queries/_show_table.html.erb
index a23ec1c..287f75e 100644
--- a/app/views/rails_pulse/queries/_show_table.html.erb
+++ b/app/views/rails_pulse/queries/_show_table.html.erb
@@ -1,16 +1,44 @@
<% columns = [
- { field: :occurred_at, label: 'Timestamp', class: 'w-auto' },
- { field: :duration, label: 'Duration', class: 'w-32'}
+ { field: :period_start, label: 'Time Period', class: 'w-auto' },
+ { field: :count, label: 'Executions', class: 'w-32'},
+ { field: :avg_duration, label: 'Avg Duration', class: 'w-32'},
+ { field: :min_duration, label: 'Min Duration', class: 'w-32'},
+ { field: :max_duration, label: 'Max Duration', class: 'w-32'}
] %>
<%= render "rails_pulse/components/table_head", columns: columns %>
- <% @table_data.each do |query| %>
+ <% @table_data.each do |summary| %>
+ <%
+ # Determine performance class based on average duration
+ avg_duration_ms = summary.avg_duration&.round(2) || 0
+ performance_class = case avg_duration_ms
+ when 0..10 then "text-green-600"
+ when 10..50 then "text-yellow-600"
+ when 50..100 then "text-orange-600"
+ else "text-red-600"
+ end
+ %>
- | <%= human_readable_occurred_at(query.occurred_at) %> |
- <%= query.duration.round(2) %> ms |
+
+ <%= human_readable_summary_period(summary) %>
+ |
+
+ <%= summary.count %>
+ |
+
+
+ <%= avg_duration_ms %> ms
+
+ |
+
+ <%= summary.min_duration&.round(2) || 0 %> ms
+ |
+
+ <%= summary.max_duration&.round(2) || 0 %> ms
+ |
<% end %>
diff --git a/app/views/rails_pulse/queries/_table.html.erb b/app/views/rails_pulse/queries/_table.html.erb
index 2bb5faa..d78439b 100644
--- a/app/views/rails_pulse/queries/_table.html.erb
+++ b/app/views/rails_pulse/queries/_table.html.erb
@@ -1,9 +1,7 @@
<% columns = [
{ field: :normalized_sql, label: 'Query', class: 'w-auto' },
- { field: :execution_count_sort, label: 'Executions', class: 'w-24' },
- { field: :avg_duration_sort, label: 'Avg Time', class: 'w-24' },
- { field: :total_time_consumed_sort, label: 'Total Time', class: 'w-28' },
- { field: :performance_status, label: 'Status', class: 'w-16', sortable: false }
+ { field: :avg_duration_sort, label: 'Average Query Time', class: 'w-44' },
+ { field: :execution_count_sort, label: 'Executions', class: 'w-24' }
] %>
@@ -17,10 +15,8 @@
<%= link_to html_escape(truncate_sql(summary.normalized_sql)), query_path(summary.query_id), data: { turbo_frame: '_top', } %>
- | <%= number_with_delimiter summary.execution_count %> |
<%= summary.avg_duration.to_i %> ms |
- <%= number_with_delimiter summary.total_time_consumed.to_i %> ms |
- <%= query_status_indicator(summary.avg_duration) %> |
+ <%= number_with_delimiter summary.execution_count %> |
<% end %>
diff --git a/app/views/rails_pulse/requests/_table.html.erb b/app/views/rails_pulse/requests/_table.html.erb
index cd8a7f2..676db66 100644
--- a/app/views/rails_pulse/requests/_table.html.erb
+++ b/app/views/rails_pulse/requests/_table.html.erb
@@ -1,28 +1,39 @@
-<%
- columns = []
- columns << { field: :route_path, label: 'Route', class: 'w-auto' } if @route.blank?
-
- columns += [
- { field: :occurred_at, label: 'Timestamp', class: 'w-36' },
- { field: :duration, label: 'Response Time', class: 'w-24' },
- { field: :status, label: 'HTTP Status', class: 'w-20' },
- { field: :status_indicator, label: 'Status', class: 'w-16' }
- ]
-%>
+<% columns = [
+ { field: :occurred_at, label: 'Timestamp', class: 'w-36' },
+ { field: :route_path, label: 'Route', class: 'w-auto' },
+ { field: :duration, label: 'Response Time', class: 'w-36' },
+ { field: :status, label: 'Status', class: 'w-20' }
+] %>
<%= render "rails_pulse/components/table_head", columns: columns %>
- <% @table_data.each do |route_request| %>
+ <% @table_data.each do |request| %>
+ <%
+ # Determine performance class based on request duration
+ performance_class = case request.duration
+ when 0..100 then "text-green-600"
+ when 100..300 then "text-yellow-600"
+ when 300..1000 then "text-orange-600"
+ else "text-red-600"
+ end
+ %>
- <% if @route.blank? %>
- | <%= link_to route_request.route.path_and_method, request_path(route_request), data: { turbo_frame: '_top' } %> |
- <% end %>
- <%= link_to human_readable_occurred_at(route_request.occurred_at), request_path(route_request), data: { turbo_frame: '_top' } %> |
- <%= route_request.duration.round(2) %> ms |
- <%= route_request.status %> |
- <%= request_status_indicator(route_request.duration) %> |
+
+ <%= link_to human_readable_occurred_at(request.occurred_at), request_path(request), data: { turbo_frame: '_top' } %>
+ |
+
+ <%= link_to "#{request.route.path} #{request.route.method}", route_path(request.route), data: { turbo_frame: '_top' } %>
+ |
+
+
+ <%= request.duration.round(2) %> ms
+
+ |
+
+ <%= request.status %>
+ |
<% end %>
diff --git a/app/views/rails_pulse/requests/index.html.erb b/app/views/rails_pulse/requests/index.html.erb
index 867a275..10ce34f 100644
--- a/app/views/rails_pulse/requests/index.html.erb
+++ b/app/views/rails_pulse/requests/index.html.erb
@@ -55,8 +55,16 @@
)
) %>
+ <% else %>
+
+ No response time data available for the selected filters.
+
<% end %>
+
+
The chart above shows aggregated average response times grouped by time periods while the table below shows specific request details.
+
+
<%= turbo_frame_tag :index_table, data: { rails_pulse__index_target: "indexTable" } do %>
<%= render 'rails_pulse/requests/table' %>
<% end %>
diff --git a/app/views/rails_pulse/requests/show.html.erb b/app/views/rails_pulse/requests/show.html.erb
index 733d4f8..834caff 100644
--- a/app/views/rails_pulse/requests/show.html.erb
+++ b/app/views/rails_pulse/requests/show.html.erb
@@ -8,8 +8,6 @@
<%= link_to @request.route.path_and_method, route_path(@request.route) %>
Timestamp
<%= human_readable_occurred_at(@request.occurred_at) %>
- Request UUID
- <%= @request.request_uuid %>
Duration
<%= @request.duration.round(2) %> ms
Status
diff --git a/app/views/rails_pulse/routes/_requests_table.html.erb b/app/views/rails_pulse/routes/_requests_table.html.erb
new file mode 100644
index 0000000..857b64e
--- /dev/null
+++ b/app/views/rails_pulse/routes/_requests_table.html.erb
@@ -0,0 +1,39 @@
+<% columns = [
+ { field: :occurred_at, label: 'Timestamp', class: 'w-auto' },
+ { field: :duration, label: 'Response Time', class: 'w-36' },
+ { field: :status, label: 'Status', class: 'w-20' }
+] %>
+
+
+ <%= render "rails_pulse/components/table_head", columns: columns %>
+
+
+ <% @table_data.each do |request| %>
+
+ |
+ <%= link_to human_readable_occurred_at(request.occurred_at), request_path(request), data: { turbo_frame: '_top' } %>
+ |
+
+ <% performance_class = case request.duration
+ when 0..100 then "text-green-600"
+ when 100..300 then "text-yellow-600"
+ when 300..1000 then "text-orange-600"
+ else "text-red-600"
+ end %>
+
+ <%= request.duration.round(2) %> ms
+
+ |
+
+ <% if request.is_error? %>
+ Error (<%= request.status %>)
+ <% else %>
+ <%= request.status %>
+ <% end %>
+ |
+
+ <% end %>
+
+
+
+<%= render "rails_pulse/components/table_pagination" %>
diff --git a/app/views/rails_pulse/routes/_table.html.erb b/app/views/rails_pulse/routes/_table.html.erb
index 0a9d7ec..3e6f314 100644
--- a/app/views/rails_pulse/routes/_table.html.erb
+++ b/app/views/rails_pulse/routes/_table.html.erb
@@ -1,11 +1,8 @@
<% columns = [
{ field: :route_path, label: 'Route', class: 'w-auto' },
- { field: :avg_duration_sort, label: 'Average Response Time', class: 'w-36' },
- { field: :max_duration_sort, label: 'Max Response Time', class: 'w-32' },
- { field: :count_sort, label: 'Requests', class: 'w-24' },
- { field: :requests_per_minute, label: 'Requests Per Minute', class: 'w-28' },
- { field: :error_rate_percentage, label: 'Error Rate (%)', class: 'w-20' },
- { field: :status_indicator, label: 'Status', class: 'w-16', sortable: false }
+ { field: :avg_duration_sort, label: 'Average Response Time', class: 'w-48' },
+ { field: :max_duration_sort, label: 'Max Response Time', class: 'w-44' },
+ { field: :count_sort, label: 'Requests', class: 'w-24' }
] %>
@@ -18,9 +15,6 @@
| <%= summary.avg_duration.to_i %> ms |
<%= summary.max_duration.to_i %> ms |
<%= number_with_delimiter summary.count %> |
- <%= summary.count < 1 ? '< 1' : (summary.count / 60.0).round(2) %> |
- <%= ((summary.error_count.to_f / summary.count) * 100).round(2) %>% |
- <%= route_status_indicator(summary.avg_duration >= 500 ? 1 : 0) %> |
<% end %>
diff --git a/app/views/rails_pulse/routes/show.html.erb b/app/views/rails_pulse/routes/show.html.erb
index 24bf189..949a647 100644
--- a/app/views/rails_pulse/routes/show.html.erb
+++ b/app/views/rails_pulse/routes/show.html.erb
@@ -1,7 +1,5 @@
<%= render 'rails_pulse/components/breadcrumbs' %>
-<%= @route.path_and_method %>
-
<% unless turbo_frame_request? %>
<%= render 'rails_pulse/components/metric_card', { class: "grid-item block", data: @average_query_times_metric_card } %>
@@ -62,7 +60,7 @@
<% end %>
<%= turbo_frame_tag :index_table, data: { rails_pulse__index_target: "indexTable" } do %>
- <%= render 'rails_pulse/requests/table' %>
+ <%= render 'rails_pulse/routes/requests_table' %>
<% end %>
<% else %>
<%= render 'rails_pulse/components/empty_state',
diff --git a/config/initializers/rails_charts_csp_patch.rb b/config/initializers/rails_charts_csp_patch.rb
index 2ac23ff..bbba541 100644
--- a/config/initializers/rails_charts_csp_patch.rb
+++ b/config/initializers/rails_charts_csp_patch.rb
@@ -1,62 +1,54 @@
-# Monkey patch for RailsCharts CSP compliance
-# This adds nonce attributes to script tags generated by the RailsCharts gem
+# CSP patch for RailsCharts gem
+# Adds nonce attributes to script tags generated by RailsCharts for CSP compliance
if defined?(RailsCharts)
module RailsCharts
module CspPatch
def line_chart(data_source, options = {})
- # Get the original chart HTML
chart_html = super(data_source, options)
+ add_csp_nonce_to_chart(chart_html)
+ end
- # Try to get CSP nonce from various sources
- nonce = get_csp_nonce
-
- if nonce.present? && chart_html.present?
- # Add nonce to all script tags in the chart HTML
- chart_html = add_nonce_to_scripts(chart_html.to_s, nonce)
- # Ensure the HTML is marked as safe for Rails to render
- chart_html = chart_html.html_safe if chart_html.respond_to?(:html_safe)
- end
-
- chart_html
+ def bar_chart(data_source, options = {})
+ chart_html = super(data_source, options)
+ add_csp_nonce_to_chart(chart_html)
end
private
- def get_csp_nonce
- # Try various methods to get the CSP nonce
- nonce = nil
+ def add_csp_nonce_to_chart(chart_html)
+ return chart_html unless chart_html.present?
- # Method 1: Check for Rails 6+ CSP nonce helper
- if respond_to?(:content_security_policy_nonce)
- nonce = content_security_policy_nonce
- end
-
- # Method 2: Check for custom csp_nonce helper
- if nonce.blank? && respond_to?(:csp_nonce)
- nonce = csp_nonce
- end
+ nonce = get_csp_nonce
+ return chart_html unless nonce.present?
- # Method 3: Check request environment
- if nonce.blank? && defined?(request) && request
- nonce = request.env["action_dispatch.content_security_policy_nonce"] ||
- request.env["secure_headers.content_security_policy_nonce"] ||
- request.env["csp_nonce"]
- end
+ # Add nonce to script tags and mark as safe
+ modified_html = add_nonce_to_scripts(chart_html.to_s, nonce)
+ modified_html.html_safe if modified_html.respond_to?(:html_safe)
+ end
- # Method 4: Check thread/request store
- if nonce.blank?
- nonce = Thread.current[:rails_pulse_csp_nonce] ||
- (defined?(RequestStore) && RequestStore.store[:rails_pulse_csp_nonce])
+ def get_csp_nonce
+ # Try common CSP nonce sources in order of preference
+ if respond_to?(:content_security_policy_nonce)
+ content_security_policy_nonce
+ elsif respond_to?(:csp_nonce)
+ csp_nonce
+ elsif defined?(request) && request
+ request.env["action_dispatch.content_security_policy_nonce"] ||
+ request.env["secure_headers.content_security_policy_nonce"] ||
+ request.env["csp_nonce"]
+ elsif respond_to?(:controller) && controller.respond_to?(:content_security_policy_nonce)
+ controller.content_security_policy_nonce
+ elsif defined?(@view_context) && @view_context.respond_to?(:content_security_policy_nonce)
+ @view_context.content_security_policy_nonce
+ else
+ Thread.current[:rails_pulse_csp_nonce] ||
+ (defined?(RequestStore) && RequestStore.store[:rails_pulse_csp_nonce])
end
-
- nonce.presence
end
def add_nonce_to_scripts(html, nonce)
- # Use regex to add nonce to script tags that don't already have one
html.gsub(/