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

- -
+
+

Issues Detected

+ <% end %> + <% if query.suggestions.present? && query.suggestions.any? %> -
-

Optimization Suggestions

- -
+
+

Optimization Suggestions

+ <% 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 + %> - - + + + + + <% 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' } ] %>
<%= 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 +
@@ -17,10 +15,8 @@ <%= link_to html_escape(truncate_sql(summary.normalized_sql)), query_path(summary.query_id), data: { turbo_frame: '_top', } %> - - - + <% 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' } +] %>
<%= 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 %>
<%= 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? %> - - <% end %> - - - - + + + + <% 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 %> + + <%= 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' } +] %> + +
<%= link_to route_request.route.path_and_method, request_path(route_request), data: { turbo_frame: '_top' } %><%= 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 %> +
+ <%= render "rails_pulse/components/table_head", columns: columns %> + + + <% @table_data.each do |request| %> + + + + + + <% end %> + +
+ <%= 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 %> +
+ +<%= 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 @@ - - - <% 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(/]*\snonce=)([^>]*)>/i) do |match| - # Insert nonce attribute before the closing > attributes = $1 if attributes.strip.empty? "
<%= 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) %>