diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 29842f3..8a840b6 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,13 +1,29 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2024-10-22 20:34:39 UTC using RuboCop version 1.67.0. +# on 2024-10-23 16:09:59 UTC using RuboCop version 1.67.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 1 +# Offense count: 3 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: Max, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. +# URISchemes: http, https +Layout/LineLength: + Exclude: + - 'config/initializers/devise.rb' + - 'config/initializers/simple_form.rb' + +# Offense count: 3 # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns, inherit_mode. # AllowedMethods: refine Metrics/BlockLength: - Max: 43 + Max: 273 + +# Offense count: 1 +# Configuration parameters: Include. +# Include: db/**/*.rb +Rails/CreateTableWithTimestamps: + Exclude: + - 'db/migrate/20241023111240_create_pghero_query_stats.rb' diff --git a/Gemfile b/Gemfile index ddb936e..a493651 100644 --- a/Gemfile +++ b/Gemfile @@ -8,18 +8,24 @@ gem "draper" gem "font-awesome-sass", "~> 6.5.2" gem "importmap-rails" gem "jbuilder" -gem "pg", "~> 1.1" gem "puma", ">= 5.0" gem "rails", "~> 7.2.1" gem "redis" gem "sassc-rails" -gem "sidekiq" gem "simple_form" gem "sprockets-rails" gem "stimulus-rails" gem "turbo-rails" gem "tzinfo-data", platforms: %i[windows jruby] +# BackgroundJob and Scheduling +gem "sidekiq" +gem "sidekiq-scheduler" + +# Database and Performance Tracking +gem "pg", "~> 1.1" +gem "pghero" + # Authentication gem "devise" diff --git a/Gemfile.lock b/Gemfile.lock index e783350..517e2e7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -159,7 +159,9 @@ GEM ruby2_keywords drb (2.2.1) erubi (1.13.0) - execjs (2.9.1) + et-orbi (1.2.11) + tzinfo + execjs (2.10.0) factory_bot (6.5.0) activesupport (>= 5.0.0) factory_bot_rails (6.4.3) @@ -178,6 +180,9 @@ GEM formtastic (5.0.0) actionpack (>= 6.0.0) formtastic_i18n (0.7.0) + fugit (1.11.1) + et-orbi (~> 1, >= 1.2.11) + raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) has_scope (0.8.2) @@ -261,12 +266,15 @@ GEM ast (~> 2.4.1) racc pg (1.5.8) + pghero (3.6.1) + activerecord (>= 6.1) popper_js (2.11.8) psych (5.1.2) stringio public_suffix (6.0.1) puma (6.4.3) nio4r (~> 2.0) + raabro (1.4.0) racc (1.8.1) rack (3.1.8) rack-mini-profiler (3.3.1) @@ -378,7 +386,7 @@ GEM rack (>= 1.1) rubocop (>= 1.52.0, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails-suite (1.1.1) + rubocop-rails-suite (1.1.2) rubocop (>= 1.67, < 2.0) rubocop-factory_bot (>= 2.26.1, < 3.0) rubocop-faker (>= 1.1.0, < 2.0) @@ -390,6 +398,8 @@ GEM ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) rubyzip (2.3.2) + rufus-scheduler (3.9.2) + fugit (~> 1.1, >= 1.11.1) sassc (2.4.0) ffi (~> 1.9) sassc-rails (2.1.2) @@ -415,6 +425,10 @@ GEM logger rack (>= 2.2.4) redis-client (>= 0.22.2) + sidekiq-scheduler (5.0.6) + rufus-scheduler (~> 3.2) + sidekiq (>= 6, < 8) + tilt (>= 1.4.0, < 3) simple_form (5.3.1) actionpack (>= 5.2) activemodel (>= 5.2) @@ -492,6 +506,7 @@ DEPENDENCIES importmap-rails jbuilder pg (~> 1.1) + pghero puma (>= 5.0) rack-mini-profiler rails (~> 7.2.1) @@ -505,6 +520,7 @@ DEPENDENCIES selenium-webdriver shoulda-matchers sidekiq + sidekiq-scheduler simple_form simplecov sprockets-rails diff --git a/README.md b/README.md index 14854d8..60073da 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,7 @@ git clone https://github.com/Grigore-George-Mihai/default_template - Update the project name to reflect your application. - Customize the [Devise](https://github.com/heartcombo/devise) configuration as only basic setup is included; adjust it to suit your specific authentication requirements. -- Modify the [Scout APM](https://github.com/scoutapp/scout_apm_ruby) settings as needed, or remove it if performance tracking is not required. -- Modify the [Rollbar](https://github.com/rollbar/rollbar-gem) settings as needed, or remove it if error tracking is not required. +- Modify or remove the settings for [Scout APM](https://github.com/scoutapp/scout_apm_ruby), [Rollbar](https://github.com/rollbar/rollbar-gem), and [PgHero](https://github.com/ankane/pghero) as needed, based on your performance, error tracking, and database monitoring requirements. - Run the following rake task to create your environment files: ```bash rake env:setup @@ -37,11 +36,20 @@ git clone https://github.com/Grigore-George-Mihai/default_template ## Gems +### Database and Monitoring +- [pg](https://github.com/ged/ruby-pg): PostgreSQL driver for Ruby, providing fast and efficient database connectivity. +- [PgHero](https://github.com/ankane/pghero): A tool for monitoring PostgreSQL database performance, including query insights, index suggestions, and table size analysis. + ### Authentication - [Devise](https://github.com/heartcombo/devise): Flexible authentication solution for Rails based on Warden. +### Admin Interface +- [ActiveAdmin](https://github.com/activeadmin/activeadmin): A flexible and extensible admin framework for Ruby on Rails applications, making it easy to build custom admin panels. +- [ActiveAdmin Addons](https://github.com/platanus/activeadmin_addons): Enhances ActiveAdmin with additional features like input widgets, searchable selects, and improved UI components for better admin interfaces. + ### Background Processing - [Sidekiq](https://github.com/mperham/sidekiq): Efficient background processing for Ruby applications. +- [Sidekiq-Scheduler](https://github.com/moove-it/sidekiq-scheduler): Extends Sidekiq to support scheduled and recurring jobs using a simple configuration. - [Redis](https://github.com/redis/redis-rb): In-memory data structure store used by Sidekiq for managing background job queues, scheduling, and retries. ### Decorator and Forms diff --git a/app/sidekiq/pg/capture_query_stats_job.rb b/app/sidekiq/pg/capture_query_stats_job.rb new file mode 100644 index 0000000..c308db4 --- /dev/null +++ b/app/sidekiq/pg/capture_query_stats_job.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "sidekiq-scheduler" + +module Pg + class CaptureQueryStatsJob + include Sidekiq::Job + + def perform + Rake::Task.clear + Rails.application.load_tasks + Rake::Task["pghero:capture_query_stats"].invoke + end + end +end diff --git a/app/sidekiq/pg/clear_query_stats_job.rb b/app/sidekiq/pg/clear_query_stats_job.rb new file mode 100644 index 0000000..e5e9f72 --- /dev/null +++ b/app/sidekiq/pg/clear_query_stats_job.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "sidekiq-scheduler" + +module Pg + class ClearQueryStatsJob + include Sidekiq::Job + + def perform + Rake::Task.clear + Rails.application.load_tasks + Rake::Task["pghero:clean_query_stats"].invoke + end + end +end diff --git a/config/database.yml b/config/database.yml index 602d876..eae65a1 100644 --- a/config/database.yml +++ b/config/database.yml @@ -18,8 +18,8 @@ default: &default # For details on connection pooling, see Rails configuration guide # https://guides.rubyonrails.org/configuring.html#database-pooling pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> - username: <%= ENV['POSTGRES_USER'] %> - password: <%= ENV['POSTGRES_PASSWORD'] %> + username: <%= ENV["POSTGRES_USER"] %> + password: <%= ENV["POSTGRES_PASSWORD"] %> development: <<: *default diff --git a/config/environments/production.rb b/config/environments/production.rb index 7d0444a..c3d1f1b 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -55,12 +55,12 @@ # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } # Log to STDOUT by default - config.logger = ActiveSupport::Logger.new(STDOUT) - .tap { |logger| logger.formatter = ::Logger::Formatter.new } - .then { |logger| ActiveSupport::TaggedLogging.new(logger) } + config.logger = ActiveSupport::Logger.new($stdout) + .tap { |logger| logger.formatter = Logger::Formatter.new } + .then { |logger| ActiveSupport::TaggedLogging.new(logger) } # Prepend all log lines with the following tags. - config.log_tags = [ :request_id ] + config.log_tags = [:request_id] # "info" includes generic and useful information about system operation, but avoids logging too much # information to avoid inadvertent exposure of personally identifiable information (PII). If you diff --git a/config/initializers/active_admin.rb b/config/initializers/active_admin.rb index 951c63f..c549161 100644 --- a/config/initializers/active_admin.rb +++ b/config/initializers/active_admin.rb @@ -134,7 +134,7 @@ # roots for each namespace. # # Default: - config.root_to = 'users#index' + config.root_to = "users#index" # == Admin Comments # @@ -168,7 +168,7 @@ # Active Admin resources and pages from here. # config.before_action do - redirect_to root_path, alert: I18n.t('active_admin.flash.unauthorized') unless current_user&.admin? + redirect_to root_path, alert: I18n.t("active_admin.flash.unauthorized") unless current_user&.admin? end # == Attribute Filters @@ -194,7 +194,7 @@ # config.favicon = 'favicon.ico' # == Meta Tags - meta_tags_options = { viewport: 'width=device-width, initial-scale=1' } + meta_tags_options = { viewport: "width=device-width, initial-scale=1" } # Add additional meta tags to the head element of active admin pages. # @@ -337,7 +337,7 @@ # By default, the footer shows the current Active Admin version. You can # override the content of the footer here. # - config.footer = 'Default Template' + config.footer = "Default Template" # == Sorting # diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index 047166a..ae7a0d4 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -9,4 +9,4 @@ # Precompile additional assets. # application.js, application.css, and all non-JS/CSS in the app/assets # folder are already added. -Rails.application.config.assets.precompile += %w(bootstrap.min.js popper.js) \ No newline at end of file +Rails.application.config.assets.precompile += %w[bootstrap.min.js popper.js] diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 7940c31..5c839ac 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -24,19 +24,19 @@ # Configure the e-mail address which will be shown in Devise::Mailer, # note that it will be overwritten if you use your own mailer class # with default "from" parameter. - config.mailer_sender = 'please-change-me-at-config-initializers-devise@example.com' + config.mailer_sender = "please-change-me-at-config-initializers-devise@example.com" # Configure the class responsible to send e-mails. - # config.mailer = 'Devise::Mailer' + # config.mailer = "Devise::Mailer" # Configure the parent class responsible to send e-mails. - # config.parent_mailer = 'ActionMailer::Base' + # config.parent_mailer = "ActionMailer::Base" # ==> ORM configuration # Load and configure the ORM. Supports :active_record (default) and # :mongoid (bson_ext recommended) by default. Other ORMs may be # available as additional gems. - require 'devise/orm/active_record' + require "devise/orm/active_record" # ==> Configuration for any authentication mechanism # Configure which keys are used when authenticating a user. The default is diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index c010b83..58277c1 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -3,6 +3,6 @@ # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. # Use this to limit dissemination of sensitive information. # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. -Rails.application.config.filter_parameters += [ - :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn +Rails.application.config.filter_parameters += %i[ + passw email secret token _key crypt salt certificate otp ssn ] diff --git a/config/initializers/rack_mini_profiler.rb b/config/initializers/rack_mini_profiler.rb index bfb38b5..14e63a3 100644 --- a/config/initializers/rack_mini_profiler.rb +++ b/config/initializers/rack_mini_profiler.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true if Rails.env.development? - require 'rack-mini-profiler' + require "rack-mini-profiler" # The initializer was required late, so initialize it manually. Rack::MiniProfilerRails.initialize!(Rails.application) diff --git a/config/initializers/rollbar.rb b/config/initializers/rollbar.rb index 29195ae..f0384fc 100644 --- a/config/initializers/rollbar.rb +++ b/config/initializers/rollbar.rb @@ -2,12 +2,10 @@ # Without configuration, Rollbar is enabled in all environments. # To disable in specific environments, set config.enabled=false. - config.access_token = ENV.fetch('ROLLBAR_ACCESS_TOKEN', nil) + config.access_token = ENV.fetch("ROLLBAR_ACCESS_TOKEN", nil) # Here we'll disable in 'test': - if Rails.env.test? - config.enabled = false - end + config.enabled = false if Rails.env.test? # By default, Rollbar will try to call the `current_user` controller method # to fetch the logged-in user object, and then call that object's `id` @@ -67,5 +65,5 @@ # environment variable like this: `ROLLBAR_ENV=staging`. This is a recommended # setup for Heroku. See: # https://devcenter.heroku.com/articles/deploying-to-a-custom-rails-environment - config.environment = ENV['ROLLBAR_ENV'].presence || Rails.env + config.environment = ENV["ROLLBAR_ENV"].presence || Rails.env end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index d1d12f2..a02be34 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -1,10 +1,10 @@ -sidekiq_config = { url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0') } +sidekiq_config = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") } Sidekiq.configure_server do |config| config.redis = sidekiq_config config.concurrency = 5 - config.queues = ['critical', 'default', 'mailers'] + config.queues = %w[critical default mailers] end Sidekiq.configure_client do |config| diff --git a/config/initializers/simple_form.rb b/config/initializers/simple_form.rb index 6d76a5d..a299e6c 100644 --- a/config/initializers/simple_form.rb +++ b/config/initializers/simple_form.rb @@ -75,7 +75,7 @@ config.boolean_style = :nested # Default class for buttons - config.button_class = 'btn' + config.button_class = "btn" # Method used to tidy up errors. Specify any Rails Array method. # :first lists the first message for each field. @@ -86,7 +86,7 @@ config.error_notification_tag = :div # CSS class to add for error notification helper. - config.error_notification_class = 'error_notification' + config.error_notification_class = "error_notification" # Series of attempts to detect a default label method for collection. # config.collection_label_methods = [ :to_label, :name, :title, :to_s ] @@ -162,7 +162,7 @@ # config.input_class = nil # Define the default class of the input wrapper of the boolean input. - config.boolean_label_class = 'checkbox' + config.boolean_label_class = "checkbox" # Defines if the default input wrapper class should be included in radio # collection wrappers. diff --git a/config/initializers/simple_form_bootstrap.rb b/config/initializers/simple_form_bootstrap.rb index 5b1790e..625b723 100644 --- a/config/initializers/simple_form_bootstrap.rb +++ b/config/initializers/simple_form_bootstrap.rb @@ -13,10 +13,10 @@ # Use this setup block to configure all options available in SimpleForm. SimpleForm.setup do |config| # Default class for buttons - config.button_class = 'btn' + config.button_class = "btn" # Define the default class of the input wrapper of the boolean input. - config.boolean_label_class = 'form-check-label' + config.boolean_label_class = "form-check-label" # How the label text should be generated altogether with the required text. config.label_text = ->(label, required, _explicit_label) { "#{label} #{required}" } @@ -32,7 +32,7 @@ config.include_default_input_wrapper_class = false # CSS class to add for error notification helper. - config.error_notification_class = 'alert alert-danger' + config.error_notification_class = "alert alert-danger" # Method used to tidy up errors. Specify any Rails Array method. # :first lists the first message for each field. @@ -40,13 +40,13 @@ config.error_method = :to_sentence # add validation classes to `input_field` - config.input_field_error_class = 'is-invalid' - config.input_field_valid_class = 'is-valid' + config.input_field_error_class = "is-invalid" + config.input_field_valid_class = "is-valid" # vertical forms # # vertical default_wrapper - config.wrappers :vertical_form, class: 'mb-3' do |b| + config.wrappers :vertical_form, class: "mb-3" do |b| b.use :html5 b.use :placeholder b.optional :maxlength @@ -54,101 +54,101 @@ b.optional :pattern b.optional :min_max b.optional :readonly - b.use :label, class: 'form-label' - b.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid' - b.use :full_error, wrap_with: { class: 'invalid-feedback' } - b.use :hint, wrap_with: { class: 'form-text' } + b.use :label, class: "form-label" + b.use :input, class: "form-control", error_class: "is-invalid", valid_class: "is-valid" + b.use :full_error, wrap_with: { class: "invalid-feedback" } + b.use :hint, wrap_with: { class: "form-text" } end # vertical input for boolean - config.wrappers :vertical_boolean, tag: 'fieldset', class: 'mb-3' do |b| + config.wrappers :vertical_boolean, tag: "fieldset", class: "mb-3" do |b| b.use :html5 b.optional :readonly - b.wrapper :form_check_wrapper, class: 'form-check' do |bb| - bb.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid' - bb.use :label, class: 'form-check-label' - bb.use :full_error, wrap_with: { class: 'invalid-feedback' } - bb.use :hint, wrap_with: { class: 'form-text' } + b.wrapper :form_check_wrapper, class: "form-check" do |bb| + bb.use :input, class: "form-check-input", error_class: "is-invalid", valid_class: "is-valid" + bb.use :label, class: "form-check-label" + bb.use :full_error, wrap_with: { class: "invalid-feedback" } + bb.use :hint, wrap_with: { class: "form-text" } end end # vertical input for radio buttons and check boxes - config.wrappers :vertical_collection, item_wrapper_class: 'form-check', item_label_class: 'form-check-label', - tag: 'fieldset', class: 'mb-3' do |b| + config.wrappers :vertical_collection, item_wrapper_class: "form-check", item_label_class: "form-check-label", + tag: "fieldset", class: "mb-3" do |b| b.use :html5 b.optional :readonly - b.wrapper :legend_tag, tag: 'legend', class: 'col-form-label pt-0' do |ba| + b.wrapper :legend_tag, tag: "legend", class: "col-form-label pt-0" do |ba| ba.use :label_text end - b.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid' - b.use :full_error, wrap_with: { class: 'invalid-feedback d-block' } - b.use :hint, wrap_with: { class: 'form-text' } + b.use :input, class: "form-check-input", error_class: "is-invalid", valid_class: "is-valid" + b.use :full_error, wrap_with: { class: "invalid-feedback d-block" } + b.use :hint, wrap_with: { class: "form-text" } end # vertical input for inline radio buttons and check boxes - config.wrappers :vertical_collection_inline, item_wrapper_class: 'form-check form-check-inline', - item_label_class: 'form-check-label', tag: 'fieldset', class: 'mb-3' do |b| + config.wrappers :vertical_collection_inline, item_wrapper_class: "form-check form-check-inline", + item_label_class: "form-check-label", tag: "fieldset", class: "mb-3" do |b| b.use :html5 b.optional :readonly - b.wrapper :legend_tag, tag: 'legend', class: 'col-form-label pt-0' do |ba| + b.wrapper :legend_tag, tag: "legend", class: "col-form-label pt-0" do |ba| ba.use :label_text end - b.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid' - b.use :full_error, wrap_with: { class: 'invalid-feedback d-block' } - b.use :hint, wrap_with: { class: 'form-text' } + b.use :input, class: "form-check-input", error_class: "is-invalid", valid_class: "is-valid" + b.use :full_error, wrap_with: { class: "invalid-feedback d-block" } + b.use :hint, wrap_with: { class: "form-text" } end # vertical file input - config.wrappers :vertical_file, class: 'mb-3' do |b| + config.wrappers :vertical_file, class: "mb-3" do |b| b.use :html5 b.use :placeholder b.optional :maxlength b.optional :minlength b.optional :readonly - b.use :label, class: 'form-label' - b.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid' - b.use :full_error, wrap_with: { class: 'invalid-feedback' } - b.use :hint, wrap_with: { class: 'form-text' } + b.use :label, class: "form-label" + b.use :input, class: "form-control", error_class: "is-invalid", valid_class: "is-valid" + b.use :full_error, wrap_with: { class: "invalid-feedback" } + b.use :hint, wrap_with: { class: "form-text" } end # vertical select input - config.wrappers :vertical_select, class: 'mb-3' do |b| + config.wrappers :vertical_select, class: "mb-3" do |b| b.use :html5 b.optional :readonly - b.use :label, class: 'form-label' - b.use :input, class: 'form-select', error_class: 'is-invalid', valid_class: 'is-valid' - b.use :full_error, wrap_with: { class: 'invalid-feedback' } - b.use :hint, wrap_with: { class: 'form-text' } + b.use :label, class: "form-label" + b.use :input, class: "form-select", error_class: "is-invalid", valid_class: "is-valid" + b.use :full_error, wrap_with: { class: "invalid-feedback" } + b.use :hint, wrap_with: { class: "form-text" } end # vertical multi select - config.wrappers :vertical_multi_select, class: 'mb-3' do |b| + config.wrappers :vertical_multi_select, class: "mb-3" do |b| b.use :html5 b.optional :readonly - b.use :label, class: 'form-label' - b.wrapper class: 'd-flex flex-row justify-content-between align-items-center' do |ba| - ba.use :input, class: 'form-select mx-1', error_class: 'is-invalid', valid_class: 'is-valid' + b.use :label, class: "form-label" + b.wrapper class: "d-flex flex-row justify-content-between align-items-center" do |ba| + ba.use :input, class: "form-select mx-1", error_class: "is-invalid", valid_class: "is-valid" end - b.use :full_error, wrap_with: { class: 'invalid-feedback d-block' } - b.use :hint, wrap_with: { class: 'form-text' } + b.use :full_error, wrap_with: { class: "invalid-feedback d-block" } + b.use :hint, wrap_with: { class: "form-text" } end # vertical range input - config.wrappers :vertical_range, class: 'mb-3' do |b| + config.wrappers :vertical_range, class: "mb-3" do |b| b.use :html5 b.use :placeholder b.optional :readonly b.optional :step - b.use :label, class: 'form-label' - b.use :input, class: 'form-range', error_class: 'is-invalid', valid_class: 'is-valid' - b.use :full_error, wrap_with: { class: 'invalid-feedback' } - b.use :hint, wrap_with: { class: 'form-text' } + b.use :label, class: "form-label" + b.use :input, class: "form-range", error_class: "is-invalid", valid_class: "is-valid" + b.use :full_error, wrap_with: { class: "invalid-feedback" } + b.use :hint, wrap_with: { class: "form-text" } end # horizontal forms # # horizontal default_wrapper - config.wrappers :horizontal_form, class: 'row mb-3' do |b| + config.wrappers :horizontal_form, class: "row mb-3" do |b| b.use :html5 b.use :placeholder b.optional :maxlength @@ -156,113 +156,113 @@ b.optional :pattern b.optional :min_max b.optional :readonly - b.use :label, class: 'col-sm-3 col-form-label' - b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba| - ba.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid' - ba.use :full_error, wrap_with: { class: 'invalid-feedback' } - ba.use :hint, wrap_with: { class: 'form-text' } + b.use :label, class: "col-sm-3 col-form-label" + b.wrapper :grid_wrapper, class: "col-sm-9" do |ba| + ba.use :input, class: "form-control", error_class: "is-invalid", valid_class: "is-valid" + ba.use :full_error, wrap_with: { class: "invalid-feedback" } + ba.use :hint, wrap_with: { class: "form-text" } end end # horizontal input for boolean - config.wrappers :horizontal_boolean, class: 'row mb-3' do |b| + config.wrappers :horizontal_boolean, class: "row mb-3" do |b| b.use :html5 b.optional :readonly - b.wrapper :grid_wrapper, class: 'col-sm-9 offset-sm-3' do |wr| - wr.wrapper :form_check_wrapper, class: 'form-check' do |bb| - bb.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid' - bb.use :label, class: 'form-check-label' - bb.use :full_error, wrap_with: { class: 'invalid-feedback' } - bb.use :hint, wrap_with: { class: 'form-text' } + b.wrapper :grid_wrapper, class: "col-sm-9 offset-sm-3" do |wr| + wr.wrapper :form_check_wrapper, class: "form-check" do |bb| + bb.use :input, class: "form-check-input", error_class: "is-invalid", valid_class: "is-valid" + bb.use :label, class: "form-check-label" + bb.use :full_error, wrap_with: { class: "invalid-feedback" } + bb.use :hint, wrap_with: { class: "form-text" } end end end # horizontal input for radio buttons and check boxes - config.wrappers :horizontal_collection, item_wrapper_class: 'form-check', item_label_class: 'form-check-label', - class: 'row mb-3' do |b| + config.wrappers :horizontal_collection, item_wrapper_class: "form-check", item_label_class: "form-check-label", + class: "row mb-3" do |b| b.use :html5 b.optional :readonly - b.use :label, class: 'col-sm-3 col-form-label pt-0' - b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba| - ba.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid' - ba.use :full_error, wrap_with: { class: 'invalid-feedback d-block' } - ba.use :hint, wrap_with: { class: 'form-text' } + b.use :label, class: "col-sm-3 col-form-label pt-0" + b.wrapper :grid_wrapper, class: "col-sm-9" do |ba| + ba.use :input, class: "form-check-input", error_class: "is-invalid", valid_class: "is-valid" + ba.use :full_error, wrap_with: { class: "invalid-feedback d-block" } + ba.use :hint, wrap_with: { class: "form-text" } end end # horizontal input for inline radio buttons and check boxes - config.wrappers :horizontal_collection_inline, item_wrapper_class: 'form-check form-check-inline', - item_label_class: 'form-check-label', class: 'row mb-3' do |b| + config.wrappers :horizontal_collection_inline, item_wrapper_class: "form-check form-check-inline", + item_label_class: "form-check-label", class: "row mb-3" do |b| b.use :html5 b.optional :readonly - b.use :label, class: 'col-sm-3 col-form-label pt-0' - b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba| - ba.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid' - ba.use :full_error, wrap_with: { class: 'invalid-feedback d-block' } - ba.use :hint, wrap_with: { class: 'form-text' } + b.use :label, class: "col-sm-3 col-form-label pt-0" + b.wrapper :grid_wrapper, class: "col-sm-9" do |ba| + ba.use :input, class: "form-check-input", error_class: "is-invalid", valid_class: "is-valid" + ba.use :full_error, wrap_with: { class: "invalid-feedback d-block" } + ba.use :hint, wrap_with: { class: "form-text" } end end # horizontal file input - config.wrappers :horizontal_file, class: 'row mb-3' do |b| + config.wrappers :horizontal_file, class: "row mb-3" do |b| b.use :html5 b.use :placeholder b.optional :maxlength b.optional :minlength b.optional :readonly - b.use :label, class: 'col-sm-3 col-form-label' - b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba| - ba.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid' - ba.use :full_error, wrap_with: { class: 'invalid-feedback' } - ba.use :hint, wrap_with: { class: 'form-text' } + b.use :label, class: "col-sm-3 col-form-label" + b.wrapper :grid_wrapper, class: "col-sm-9" do |ba| + ba.use :input, class: "form-control", error_class: "is-invalid", valid_class: "is-valid" + ba.use :full_error, wrap_with: { class: "invalid-feedback" } + ba.use :hint, wrap_with: { class: "form-text" } end end # horizontal select input - config.wrappers :horizontal_select, class: 'row mb-3' do |b| + config.wrappers :horizontal_select, class: "row mb-3" do |b| b.use :html5 b.optional :readonly - b.use :label, class: 'col-sm-3 col-form-label' - b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba| - ba.use :input, class: 'form-select', error_class: 'is-invalid', valid_class: 'is-valid' - ba.use :full_error, wrap_with: { class: 'invalid-feedback' } - ba.use :hint, wrap_with: { class: 'form-text' } + b.use :label, class: "col-sm-3 col-form-label" + b.wrapper :grid_wrapper, class: "col-sm-9" do |ba| + ba.use :input, class: "form-select", error_class: "is-invalid", valid_class: "is-valid" + ba.use :full_error, wrap_with: { class: "invalid-feedback" } + ba.use :hint, wrap_with: { class: "form-text" } end end # horizontal multi select - config.wrappers :horizontal_multi_select, class: 'row mb-3' do |b| + config.wrappers :horizontal_multi_select, class: "row mb-3" do |b| b.use :html5 b.optional :readonly - b.use :label, class: 'col-sm-3 col-form-label' - b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba| - ba.wrapper class: 'd-flex flex-row justify-content-between align-items-center' do |bb| - bb.use :input, class: 'form-select mx-1', error_class: 'is-invalid', valid_class: 'is-valid' + b.use :label, class: "col-sm-3 col-form-label" + b.wrapper :grid_wrapper, class: "col-sm-9" do |ba| + ba.wrapper class: "d-flex flex-row justify-content-between align-items-center" do |bb| + bb.use :input, class: "form-select mx-1", error_class: "is-invalid", valid_class: "is-valid" end - ba.use :full_error, wrap_with: { class: 'invalid-feedback d-block' } - ba.use :hint, wrap_with: { class: 'form-text' } + ba.use :full_error, wrap_with: { class: "invalid-feedback d-block" } + ba.use :hint, wrap_with: { class: "form-text" } end end # horizontal range input - config.wrappers :horizontal_range, class: 'row mb-3' do |b| + config.wrappers :horizontal_range, class: "row mb-3" do |b| b.use :html5 b.use :placeholder b.optional :readonly b.optional :step - b.use :label, class: 'col-sm-3 col-form-label pt-0' - b.wrapper :grid_wrapper, class: 'col-sm-9' do |ba| - ba.use :input, class: 'form-range', error_class: 'is-invalid', valid_class: 'is-valid' - ba.use :full_error, wrap_with: { class: 'invalid-feedback' } - ba.use :hint, wrap_with: { class: 'form-text' } + b.use :label, class: "col-sm-3 col-form-label pt-0" + b.wrapper :grid_wrapper, class: "col-sm-9" do |ba| + ba.use :input, class: "form-range", error_class: "is-invalid", valid_class: "is-valid" + ba.use :full_error, wrap_with: { class: "invalid-feedback" } + ba.use :hint, wrap_with: { class: "form-text" } end end # inline forms # # inline default_wrapper - config.wrappers :inline_form, class: 'col-12' do |b| + config.wrappers :inline_form, class: "col-12" do |b| b.use :html5 b.use :placeholder b.optional :maxlength @@ -270,42 +270,42 @@ b.optional :pattern b.optional :min_max b.optional :readonly - b.use :label, class: 'visually-hidden' + b.use :label, class: "visually-hidden" - b.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid' - b.use :error, wrap_with: { class: 'invalid-feedback' } - b.optional :hint, wrap_with: { class: 'form-text' } + b.use :input, class: "form-control", error_class: "is-invalid", valid_class: "is-valid" + b.use :error, wrap_with: { class: "invalid-feedback" } + b.optional :hint, wrap_with: { class: "form-text" } end # inline input for boolean - config.wrappers :inline_boolean, class: 'col-12' do |b| + config.wrappers :inline_boolean, class: "col-12" do |b| b.use :html5 b.optional :readonly - b.wrapper :form_check_wrapper, class: 'form-check' do |bb| - bb.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid' - bb.use :label, class: 'form-check-label' - bb.use :error, wrap_with: { class: 'invalid-feedback' } - bb.optional :hint, wrap_with: { class: 'form-text' } + b.wrapper :form_check_wrapper, class: "form-check" do |bb| + bb.use :input, class: "form-check-input", error_class: "is-invalid", valid_class: "is-valid" + bb.use :label, class: "form-check-label" + bb.use :error, wrap_with: { class: "invalid-feedback" } + bb.optional :hint, wrap_with: { class: "form-text" } end end # bootstrap custom forms # # custom input switch for boolean - config.wrappers :custom_boolean_switch, class: 'mb-3' do |b| + config.wrappers :custom_boolean_switch, class: "mb-3" do |b| b.use :html5 b.optional :readonly - b.wrapper :form_check_wrapper, tag: 'div', class: 'form-check form-switch' do |bb| - bb.use :input, class: 'form-check-input', error_class: 'is-invalid', valid_class: 'is-valid' - bb.use :label, class: 'form-check-label' - bb.use :full_error, wrap_with: { tag: 'div', class: 'invalid-feedback' } - bb.use :hint, wrap_with: { class: 'form-text' } + b.wrapper :form_check_wrapper, tag: "div", class: "form-check form-switch" do |bb| + bb.use :input, class: "form-check-input", error_class: "is-invalid", valid_class: "is-valid" + bb.use :label, class: "form-check-label" + bb.use :full_error, wrap_with: { tag: "div", class: "invalid-feedback" } + bb.use :hint, wrap_with: { class: "form-text" } end end # Input Group - custom component # see example app and config at https://github.com/heartcombo/simple_form-bootstrap - config.wrappers :input_group, class: 'mb-3' do |b| + config.wrappers :input_group, class: "mb-3" do |b| b.use :html5 b.use :placeholder b.optional :maxlength @@ -313,20 +313,20 @@ b.optional :pattern b.optional :min_max b.optional :readonly - b.use :label, class: 'form-label' - b.wrapper :input_group_tag, class: 'input-group' do |ba| + b.use :label, class: "form-label" + b.wrapper :input_group_tag, class: "input-group" do |ba| ba.optional :prepend - ba.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid' + ba.use :input, class: "form-control", error_class: "is-invalid", valid_class: "is-valid" ba.optional :append - ba.use :full_error, wrap_with: { class: 'invalid-feedback' } + ba.use :full_error, wrap_with: { class: "invalid-feedback" } end - b.use :hint, wrap_with: { class: 'form-text' } + b.use :hint, wrap_with: { class: "form-text" } end # Floating Labels form # # floating labels default_wrapper - config.wrappers :floating_labels_form, class: 'form-floating mb-3' do |b| + config.wrappers :floating_labels_form, class: "form-floating mb-3" do |b| b.use :html5 b.use :placeholder b.optional :maxlength @@ -334,20 +334,20 @@ b.optional :pattern b.optional :min_max b.optional :readonly - b.use :input, class: 'form-control', error_class: 'is-invalid', valid_class: 'is-valid' + b.use :input, class: "form-control", error_class: "is-invalid", valid_class: "is-valid" b.use :label - b.use :full_error, wrap_with: { class: 'invalid-feedback' } - b.use :hint, wrap_with: { class: 'form-text' } + b.use :full_error, wrap_with: { class: "invalid-feedback" } + b.use :hint, wrap_with: { class: "form-text" } end # custom multi select - config.wrappers :floating_labels_select, class: 'form-floating mb-3' do |b| + config.wrappers :floating_labels_select, class: "form-floating mb-3" do |b| b.use :html5 b.optional :readonly - b.use :input, class: 'form-select', error_class: 'is-invalid', valid_class: 'is-valid' + b.use :input, class: "form-select", error_class: "is-invalid", valid_class: "is-valid" b.use :label - b.use :full_error, wrap_with: { class: 'invalid-feedback' } - b.use :hint, wrap_with: { class: 'form-text' } + b.use :full_error, wrap_with: { class: "invalid-feedback" } + b.use :hint, wrap_with: { class: "form-text" } end # The default wrapper to be used by the FormBuilder. diff --git a/config/routes.rb b/config/routes.rb index 0a4f53c..29fa7d4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,4 @@ -require 'sidekiq/web' +require "sidekiq/web" Rails.application.routes.draw do ActiveAdmin.routes(self) @@ -7,6 +7,7 @@ # Mount Sidekiq authenticate :user, ->(user) { user.admin? } do mount Sidekiq::Web => "/sidekiq" + mount PgHero::Engine => "/pghero" end # Define root route diff --git a/config/scout_apm.yml b/config/scout_apm.yml index 9cd4aa0..484c70c 100644 --- a/config/scout_apm.yml +++ b/config/scout_apm.yml @@ -1,8 +1,8 @@ common: &defaults monitor: true log_level: debug - key: <%= ENV.fetch('SCOUT_KEY', nil) %> - name: <%= ENV.fetch('SCOUT_NAME', nil) %> + key: <%= ENV.fetch("SCOUT_KEY", nil) %> + name: <%= ENV.fetch("SCOUT_NAME", nil) %> production: <<: *defaults diff --git a/config/sidekiq.yml b/config/sidekiq.yml new file mode 100644 index 0000000..c50951c --- /dev/null +++ b/config/sidekiq.yml @@ -0,0 +1,11 @@ +:scheduler: + :schedule: + capture_query_stats_job: + :environment: production + cron: "0 * * * *" # Runs every hour + class: "Pg::CaptureQueryStatsJob" + + clear_query_stats_job: + :environment: production + cron: "0 0 * * 0" # Runs weekly on Sundays at midnight + class: "Pg::ClearQueryStatsJob" diff --git a/db/migrate/20241023111240_create_pghero_query_stats.rb b/db/migrate/20241023111240_create_pghero_query_stats.rb new file mode 100644 index 0000000..ad6b8b4 --- /dev/null +++ b/db/migrate/20241023111240_create_pghero_query_stats.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreatePgheroQueryStats < ActiveRecord::Migration[7.2] + def change + create_table :pghero_query_stats do |t| + t.text :database + t.text :user + t.text :query + t.integer :query_hash, limit: 8 + t.float :total_time + t.integer :calls, limit: 8 + t.timestamp :captured_at + end + + add_index :pghero_query_stats, %i[database captured_at] + end +end diff --git a/db/schema.rb b/db/schema.rb index b693836..45f9114 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,13 +10,24 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_10_22_131238) do +ActiveRecord::Schema[7.2].define(version: 2024_10_23_111240) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "pghero_query_stats", force: :cascade do |t| + t.text "database" + t.text "user" + t.text "query" + t.bigint "query_hash" + t.float "total_time" + t.bigint "calls" + t.datetime "captured_at", precision: nil + t.index ["database", "captured_at"], name: "index_pghero_query_stats_on_database_and_captured_at" + end + create_table "users", force: :cascade do |t| - t.string "first_name", null: false - t.string "last_name", null: false + t.string "first_name", default: "", null: false + t.string "last_name", default: "", null: false t.string "email", default: "", null: false t.string "encrypted_password", default: "", null: false t.string "reset_password_token" diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 2c99997..bc6a73e 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -1,27 +1,27 @@ # frozen_string_literal: true -require 'rails_helper' +require "rails_helper" RSpec.describe ApplicationHelper, type: :helper do - describe '#bootstrap_class_for' do - it 'returns the correct Bootstrap class for success' do - expect(helper.bootstrap_class_for(:success)).to eq('success') + describe "#bootstrap_class_for" do + it "returns the correct Bootstrap class for success" do + expect(helper.bootstrap_class_for(:success)).to eq("success") end - it 'returns the correct Bootstrap class for error' do - expect(helper.bootstrap_class_for(:error)).to eq('danger') + it "returns the correct Bootstrap class for error" do + expect(helper.bootstrap_class_for(:error)).to eq("danger") end - it 'returns the correct Bootstrap class for alert' do - expect(helper.bootstrap_class_for(:alert)).to eq('warning') + it "returns the correct Bootstrap class for alert" do + expect(helper.bootstrap_class_for(:alert)).to eq("warning") end - it 'returns the correct Bootstrap class for notice' do - expect(helper.bootstrap_class_for(:notice)).to eq('info') + it "returns the correct Bootstrap class for notice" do + expect(helper.bootstrap_class_for(:notice)).to eq("info") end - it 'returns the flash type as a string if it is not mapped' do - expect(helper.bootstrap_class_for(:unknown)).to eq('unknown') + it "returns the flash type as a string if it is not mapped" do + expect(helper.bootstrap_class_for(:unknown)).to eq("unknown") end end end diff --git a/spec/sidekiq/pg/capture_query_stats_job_spec.rb b/spec/sidekiq/pg/capture_query_stats_job_spec.rb new file mode 100644 index 0000000..80974d1 --- /dev/null +++ b/spec/sidekiq/pg/capture_query_stats_job_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Pg::CaptureQueryStatsJob, type: :job do + let(:rake_task) { instance_double(Rake::Task) } + + before do + allow(Rake::Task).to receive(:clear) + allow(Rails.application).to receive(:load_tasks) + allow(Rake::Task).to receive(:[]).with("pghero:capture_query_stats").and_return(rake_task) + allow(rake_task).to receive(:invoke) + end + + it "enqueues the job" do + expect do + described_class.perform_async + end.to change(described_class.jobs, :size).by(1) + end + + it "clears the Rake tasks" do + described_class.new.perform + expect(Rake::Task).to have_received(:clear) + end + + it "loads the Rake tasks" do + described_class.new.perform + expect(Rails.application).to have_received(:load_tasks) + end + + it "invokes the pghero capture query stats task" do + described_class.new.perform + expect(rake_task).to have_received(:invoke) + end +end diff --git a/spec/sidekiq/pg/clear_query_stats_job_spec.rb b/spec/sidekiq/pg/clear_query_stats_job_spec.rb new file mode 100644 index 0000000..cfa75e2 --- /dev/null +++ b/spec/sidekiq/pg/clear_query_stats_job_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Pg::ClearQueryStatsJob, type: :job do + let(:rake_task) { instance_double(Rake::Task) } + + before do + allow(Rake::Task).to receive(:clear) + allow(Rails.application).to receive(:load_tasks) + allow(Rake::Task).to receive(:[]).with("pghero:clean_query_stats").and_return(rake_task) + allow(rake_task).to receive(:invoke) + end + + it "enqueues the job" do + expect do + described_class.perform_async + end.to change(described_class.jobs, :size).by(1) + end + + it "clears the Rake tasks" do + described_class.new.perform + expect(Rake::Task).to have_received(:clear) + end + + it "loads the Rake tasks" do + described_class.new.perform + expect(Rails.application).to have_received(:load_tasks) + end + + it "invokes the pghero clean query stats task" do + described_class.new.perform + expect(rake_task).to have_received(:invoke) + end +end