├── .github ├── CODEOWNERS └── workflows │ ├── ci.yml │ └── main.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── app ├── controllers │ └── solid_queue_monitor │ │ ├── application_controller.rb │ │ ├── base_controller.rb │ │ ├── failed_jobs_controller.rb │ │ ├── in_progress_jobs_controller.rb │ │ ├── overview_controller.rb │ │ ├── queues_controller.rb │ │ ├── ready_jobs_controller.rb │ │ ├── recurring_jobs_controller.rb │ │ └── scheduled_jobs_controller.rb ├── presenters │ └── solid_queue_monitor │ │ ├── base_presenter.rb │ │ ├── failed_jobs_presenter.rb │ │ ├── in_progress_jobs_presenter.rb │ │ ├── jobs_presenter.rb │ │ ├── queues_presenter.rb │ │ ├── ready_jobs_presenter.rb │ │ ├── recurring_jobs_presenter.rb │ │ ├── scheduled_jobs_presenter.rb │ │ └── stats_presenter.rb └── services │ └── solid_queue_monitor │ ├── authentication_service.rb │ ├── execute_job_service.rb │ ├── failed_job_service.rb │ ├── html_generator.rb │ ├── pagination_service.rb │ ├── stats_calculator.rb │ ├── status_calculator.rb │ └── stylesheet_generator.rb ├── bin ├── console └── setup ├── config ├── initializers │ └── solid_queue_monitor.rb └── routes.rb ├── lib ├── generators │ └── solid_queue_monitor │ │ ├── install_generator.rb │ │ └── templates │ │ ├── README.md │ │ └── initializer.rb ├── solid_queue_monitor.rb ├── solid_queue_monitor │ ├── engine.rb │ └── version.rb └── tasks │ └── app.rake ├── log └── test.log ├── screenshots ├── .gitkeep ├── dashboard-2.png ├── dashboard-3.png ├── dashboard.png ├── failed-jobs-2.png ├── failed_jobs.png └── recurring_jobs.png ├── sig └── solid_queue_monitor.rbs ├── solid_queue_monitor.gemspec └── spec ├── controllers └── solid_queue_monitor │ └── failed_jobs_controller_spec.rb ├── dummy ├── Rakefile ├── application.rb ├── config │ ├── application.rb │ ├── boot.rb │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ └── test.rb │ └── routes.rb ├── db │ ├── .gitkeep │ ├── migrate │ │ └── 20240311000000_create_solid_queue_tables.rb │ └── schema.rb ├── log │ └── test.log └── tmp │ └── local_secret.txt ├── features └── solid_queue_monitor │ └── dashboard_spec.rb ├── presenters └── solid_queue_monitor │ ├── jobs_presenter_spec.rb │ └── stats_presenter_spec.rb ├── services └── solid_queue_monitor │ ├── authentication_service_spec.rb │ ├── execute_job_service_spec.rb │ ├── pagination_service_spec.rb │ ├── stats_calculator_spec.rb │ └── status_calculator_spec.rb └── spec_helper.rb /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in 2 | # the repo. Unless a later match takes precedence, 3 | # @vishaltps will be requested for review when someone 4 | # opens a pull request. 5 | # 6 | * @vishaltps -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Ruby 18 | uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: "3.2" 21 | bundler-cache: false 22 | 23 | - name: Install dependencies 24 | run: | 25 | gem install bundler -v '2.4.22' 26 | bundle config set --local path 'vendor/bundle' 27 | bundle config set --local frozen 'false' 28 | bundle install 29 | 30 | - name: Run RuboCop 31 | run: bundle exec rubocop --parallel 32 | 33 | # - name: Run RSpec 34 | # run: bundle exec rspec 35 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # name: Ruby Gem 2 | 3 | # on: 4 | # push: 5 | # branches: [main] 6 | # pull_request: 7 | # branches: [main] 8 | 9 | # jobs: 10 | # build: 11 | # runs-on: ubuntu-latest 12 | # name: Ruby ${{ matrix.ruby }} 13 | # strategy: 14 | # matrix: 15 | # ruby: ["3.1.6", "3.2.3", "3.3.0"] 16 | 17 | # steps: 18 | # - uses: actions/checkout@v3 19 | # - name: Set up Ruby 20 | # uses: ruby/setup-ruby@v1 21 | # with: 22 | # ruby-version: ${{ matrix.ruby }} 23 | # bundler-cache: false 24 | 25 | # - name: Bundle install 26 | # run: | 27 | # bundle config set frozen false 28 | # bundle install 29 | 30 | # # - name: Run tests 31 | # # run: bundle exec rake 32 | 33 | # lint: 34 | # runs-on: ubuntu-latest 35 | # steps: 36 | # - uses: actions/checkout@v3 37 | # - name: Set up Ruby 38 | # uses: ruby/setup-ruby@v1 39 | # with: 40 | # ruby-version: "3.2" 41 | # bundler-cache: false 42 | 43 | # - name: Bundle install 44 | # run: | 45 | # bundle config set frozen false 46 | # bundle install 47 | 48 | # - name: Run rubocop 49 | # run: bundle exec rubocop 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | 11 | .DS_Store 12 | .rspec_status 13 | 14 | *.gem 15 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | --color 3 | --format documentation -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-rspec 3 | 4 | plugins: 5 | - rubocop-factory_bot 6 | - rubocop-rails 7 | 8 | AllCops: 9 | NewCops: enable 10 | TargetRubyVersion: 3.0 11 | SuggestExtensions: false 12 | Exclude: 13 | - 'db/**/*' 14 | - 'config/**/*' 15 | - 'bin/**/*' 16 | - 'vendor/**/*' 17 | - 'node_modules/**/*' 18 | - 'spec/dummy/**/*' 19 | 20 | Style/Documentation: 21 | Enabled: false 22 | 23 | Metrics/ClassLength: 24 | Max: 500 25 | 26 | Metrics/ModuleLength: 27 | Max: 200 28 | 29 | Metrics/MethodLength: 30 | Max: 50 31 | 32 | Metrics/BlockLength: 33 | Max: 100 34 | 35 | Metrics/AbcSize: 36 | Max: 35 37 | Exclude: 38 | - 'app/controllers/solid_queue_monitor/base_controller.rb' 39 | 40 | Metrics/CyclomaticComplexity: 41 | Max: 10 42 | 43 | Metrics/PerceivedComplexity: 44 | Max: 15 45 | 46 | Layout/LineLength: 47 | Max: 150 48 | 49 | RSpec/MultipleExpectations: 50 | Max: 15 51 | 52 | RSpec/ExampleLength: 53 | Max: 20 54 | 55 | RSpec/IndexedLet: 56 | Enabled: false 57 | 58 | RSpec/AnyInstance: 59 | Enabled: false 60 | 61 | RSpec/NamedSubject: 62 | Enabled: false 63 | 64 | RSpec/LetSetup: 65 | Enabled: false 66 | 67 | Capybara/RSpec/PredicateMatcher: 68 | Enabled: false 69 | 70 | Capybara/NegationMatcher: 71 | Enabled: false 72 | 73 | Capybara/ClickLinkOrButtonStyle: 74 | Enabled: false 75 | 76 | FactoryBot: 77 | Enabled: false 78 | 79 | Lint/MissingSuper: 80 | Enabled: false 81 | 82 | Rails/OutputSafety: 83 | Enabled: false -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.3.1] - 2024-03-28 4 | 5 | ### Improved 6 | 7 | - Enhanced job arguments display in tables with better formatting 8 | - Improved handling of different argument types (keyword args and plain arrays) 9 | - Added scrollable container for long argument values with styled scrollbar 10 | - Fixed duplicate argument display issues 11 | - Optimized space usage in job tables 12 | 13 | ## [0.3.0] - 2024-05-27 14 | 15 | ### Added 16 | 17 | - Added arguments filtering across all job views (Overview, Ready, Scheduled, In Progress, Failed) 18 | - Implemented ILIKE search for arguments to allow partial case-insensitive matching 19 | - Added arguments column to In Progress jobs view 20 | 21 | ### Changed 22 | 23 | - Improved job filtering capabilities for more effective debugging 24 | - Optimized database queries for arguments filtering 25 | 26 | ## [0.2.0] - 2023-03-28 27 | 28 | ### Added 29 | 30 | - Redesigned with RESTful architecture using separate controllers for each resource 31 | - Added monitoring for In Progress jobs using the SolidQueue claimed executions table 32 | - Added direct retry/discard actions for failed jobs in the Recent Jobs view 33 | - Added improved pagination with ellipsis for better navigation 34 | - Added CSS styling for inline forms to improve action buttons layout 35 | 36 | ### Changed 37 | 38 | - Limited Recent Jobs to 100 entries for better performance in high-volume applications 39 | - Reorganized navigation and stat cards to follow logical job lifecycle 40 | - Improved the redirect handling for job actions to maintain context 41 | - Restructured HTML generation for more consistent table layouts 42 | - Optimized database queries for job status determination 43 | 44 | ### Fixed 45 | 46 | - Fixed pagination display for large result sets 47 | - Fixed routing issues with controller namespacing 48 | - Fixed redirect behavior after job actions 49 | 50 | ## [0.1.2] - 2024-03-18 51 | 52 | ### Added 53 | 54 | - Ability to retry failed jobs individually or in bulk 55 | - Ability to discard failed jobs individually or in bulk 56 | - Improved error display with collapsible backtrace 57 | 58 | ## [0.1.1] - 2024-03-16 59 | 60 | ### Changed 61 | 62 | - Added CSS scoping with `.solid_queue_monitor` parent class to prevent style conflicts with host applications 63 | - Improved compatibility with various Rails applications and styling frameworks 64 | 65 | ## [0.1.0] - 2024-03-15 66 | 67 | ### Added 68 | 69 | - Initial release 70 | - Dashboard overview with job statistics 71 | - Job filtering by class name, queue name, and status 72 | - Support for viewing ready, scheduled, recurring, and failed jobs 73 | - Queue monitoring 74 | - Pagination for job lists 75 | - Optional HTTP Basic Authentication 76 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in solid_queue_monitor.gemspec 6 | gemspec 7 | 8 | group :development, :test do 9 | gem 'factory_bot_rails' 10 | gem 'rspec-rails' 11 | gem 'rubocop' 12 | gem 'rubocop-rails' 13 | gem 'rubocop-rspec' 14 | end 15 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | solid_queue_monitor (0.2.0) 5 | rails (>= 7.0) 6 | solid_queue (>= 0.1.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | actioncable (8.0.2) 12 | actionpack (= 8.0.2) 13 | activesupport (= 8.0.2) 14 | nio4r (~> 2.0) 15 | websocket-driver (>= 0.6.1) 16 | zeitwerk (~> 2.6) 17 | actionmailbox (8.0.2) 18 | actionpack (= 8.0.2) 19 | activejob (= 8.0.2) 20 | activerecord (= 8.0.2) 21 | activestorage (= 8.0.2) 22 | activesupport (= 8.0.2) 23 | mail (>= 2.8.0) 24 | actionmailer (8.0.2) 25 | actionpack (= 8.0.2) 26 | actionview (= 8.0.2) 27 | activejob (= 8.0.2) 28 | activesupport (= 8.0.2) 29 | mail (>= 2.8.0) 30 | rails-dom-testing (~> 2.2) 31 | actionpack (8.0.2) 32 | actionview (= 8.0.2) 33 | activesupport (= 8.0.2) 34 | nokogiri (>= 1.8.5) 35 | rack (>= 2.2.4) 36 | rack-session (>= 1.0.1) 37 | rack-test (>= 0.6.3) 38 | rails-dom-testing (~> 2.2) 39 | rails-html-sanitizer (~> 1.6) 40 | useragent (~> 0.16) 41 | actiontext (8.0.2) 42 | actionpack (= 8.0.2) 43 | activerecord (= 8.0.2) 44 | activestorage (= 8.0.2) 45 | activesupport (= 8.0.2) 46 | globalid (>= 0.6.0) 47 | nokogiri (>= 1.8.5) 48 | actionview (8.0.2) 49 | activesupport (= 8.0.2) 50 | builder (~> 3.1) 51 | erubi (~> 1.11) 52 | rails-dom-testing (~> 2.2) 53 | rails-html-sanitizer (~> 1.6) 54 | activejob (8.0.2) 55 | activesupport (= 8.0.2) 56 | globalid (>= 0.3.6) 57 | activemodel (8.0.2) 58 | activesupport (= 8.0.2) 59 | activerecord (8.0.2) 60 | activemodel (= 8.0.2) 61 | activesupport (= 8.0.2) 62 | timeout (>= 0.4.0) 63 | activestorage (8.0.2) 64 | actionpack (= 8.0.2) 65 | activejob (= 8.0.2) 66 | activerecord (= 8.0.2) 67 | activesupport (= 8.0.2) 68 | marcel (~> 1.0) 69 | activesupport (8.0.2) 70 | base64 71 | benchmark (>= 0.3) 72 | bigdecimal 73 | concurrent-ruby (~> 1.0, >= 1.3.1) 74 | connection_pool (>= 2.2.5) 75 | drb 76 | i18n (>= 1.6, < 2) 77 | logger (>= 1.4.2) 78 | minitest (>= 5.1) 79 | securerandom (>= 0.3) 80 | tzinfo (~> 2.0, >= 2.0.5) 81 | uri (>= 0.13.1) 82 | ast (2.4.2) 83 | base64 (0.2.0) 84 | benchmark (0.4.0) 85 | bigdecimal (3.1.9) 86 | builder (3.3.0) 87 | concurrent-ruby (1.3.5) 88 | connection_pool (2.5.0) 89 | crass (1.0.6) 90 | date (3.4.1) 91 | diff-lcs (1.6.0) 92 | drb (2.2.1) 93 | erubi (1.13.1) 94 | et-orbi (1.2.11) 95 | tzinfo 96 | factory_bot (6.5.1) 97 | activesupport (>= 6.1.0) 98 | factory_bot_rails (6.4.4) 99 | factory_bot (~> 6.5) 100 | railties (>= 5.0.0) 101 | fugit (1.11.1) 102 | et-orbi (~> 1, >= 1.2.11) 103 | raabro (~> 1.4) 104 | globalid (1.2.1) 105 | activesupport (>= 6.1) 106 | i18n (1.14.7) 107 | concurrent-ruby (~> 1.0) 108 | io-console (0.8.0) 109 | irb (1.15.1) 110 | pp (>= 0.6.0) 111 | rdoc (>= 4.0.0) 112 | reline (>= 0.4.2) 113 | json (2.10.2) 114 | language_server-protocol (3.17.0.4) 115 | lint_roller (1.1.0) 116 | logger (1.6.6) 117 | loofah (2.24.0) 118 | crass (~> 1.0.2) 119 | nokogiri (>= 1.12.0) 120 | mail (2.8.1) 121 | mini_mime (>= 0.1.1) 122 | net-imap 123 | net-pop 124 | net-smtp 125 | marcel (1.0.4) 126 | mini_mime (1.1.5) 127 | mini_portile2 (2.8.8) 128 | minitest (5.25.5) 129 | net-imap (0.5.6) 130 | date 131 | net-protocol 132 | net-pop (0.1.2) 133 | net-protocol 134 | net-protocol (0.2.2) 135 | timeout 136 | net-smtp (0.5.1) 137 | net-protocol 138 | nio4r (2.7.4) 139 | nokogiri (1.18.4) 140 | mini_portile2 (~> 2.8.2) 141 | racc (~> 1.4) 142 | nokogiri (1.18.4-arm64-darwin) 143 | racc (~> 1.4) 144 | parallel (1.26.3) 145 | parser (3.3.7.1) 146 | ast (~> 2.4.1) 147 | racc 148 | pp (0.6.2) 149 | prettyprint 150 | prettyprint (0.2.0) 151 | psych (5.2.3) 152 | date 153 | stringio 154 | raabro (1.4.0) 155 | racc (1.8.1) 156 | rack (3.1.12) 157 | rack-session (2.1.0) 158 | base64 (>= 0.1.0) 159 | rack (>= 3.0.0) 160 | rack-test (2.2.0) 161 | rack (>= 1.3) 162 | rackup (2.2.1) 163 | rack (>= 3) 164 | rails (8.0.2) 165 | actioncable (= 8.0.2) 166 | actionmailbox (= 8.0.2) 167 | actionmailer (= 8.0.2) 168 | actionpack (= 8.0.2) 169 | actiontext (= 8.0.2) 170 | actionview (= 8.0.2) 171 | activejob (= 8.0.2) 172 | activemodel (= 8.0.2) 173 | activerecord (= 8.0.2) 174 | activestorage (= 8.0.2) 175 | activesupport (= 8.0.2) 176 | bundler (>= 1.15.0) 177 | railties (= 8.0.2) 178 | rails-dom-testing (2.2.0) 179 | activesupport (>= 5.0.0) 180 | minitest 181 | nokogiri (>= 1.6) 182 | rails-html-sanitizer (1.6.2) 183 | loofah (~> 2.21) 184 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 185 | railties (8.0.2) 186 | actionpack (= 8.0.2) 187 | activesupport (= 8.0.2) 188 | irb (~> 1.13) 189 | rackup (>= 1.0.0) 190 | rake (>= 12.2) 191 | thor (~> 1.0, >= 1.2.2) 192 | zeitwerk (~> 2.6) 193 | rainbow (3.1.1) 194 | rake (13.2.1) 195 | rdoc (6.12.0) 196 | psych (>= 4.0.0) 197 | regexp_parser (2.10.0) 198 | reline (0.6.0) 199 | io-console (~> 0.5) 200 | rspec-core (3.13.3) 201 | rspec-support (~> 3.13.0) 202 | rspec-expectations (3.13.3) 203 | diff-lcs (>= 1.2.0, < 2.0) 204 | rspec-support (~> 3.13.0) 205 | rspec-mocks (3.13.2) 206 | diff-lcs (>= 1.2.0, < 2.0) 207 | rspec-support (~> 3.13.0) 208 | rspec-rails (6.1.5) 209 | actionpack (>= 6.1) 210 | activesupport (>= 6.1) 211 | railties (>= 6.1) 212 | rspec-core (~> 3.13) 213 | rspec-expectations (~> 3.13) 214 | rspec-mocks (~> 3.13) 215 | rspec-support (~> 3.13) 216 | rspec-support (3.13.2) 217 | rubocop (1.74.0) 218 | json (~> 2.3) 219 | language_server-protocol (~> 3.17.0.2) 220 | lint_roller (~> 1.1.0) 221 | parallel (~> 1.10) 222 | parser (>= 3.3.0.2) 223 | rainbow (>= 2.2.2, < 4.0) 224 | regexp_parser (>= 2.9.3, < 3.0) 225 | rubocop-ast (>= 1.38.0, < 2.0) 226 | ruby-progressbar (~> 1.7) 227 | unicode-display_width (>= 2.4.0, < 4.0) 228 | rubocop-ast (1.38.1) 229 | parser (>= 3.3.1.0) 230 | rubocop-capybara (2.22.1) 231 | lint_roller (~> 1.1) 232 | rubocop (~> 1.72, >= 1.72.1) 233 | rubocop-factory_bot (2.27.1) 234 | lint_roller (~> 1.1) 235 | rubocop (~> 1.72, >= 1.72.1) 236 | rubocop-rails (2.30.3) 237 | activesupport (>= 4.2.0) 238 | lint_roller (~> 1.1) 239 | rack (>= 1.1) 240 | rubocop (>= 1.72.1, < 2.0) 241 | rubocop-ast (>= 1.38.0, < 2.0) 242 | rubocop-rspec (2.31.0) 243 | rubocop (~> 1.40) 244 | rubocop-capybara (~> 2.17) 245 | rubocop-factory_bot (~> 2.22) 246 | rubocop-rspec_rails (~> 2.28) 247 | rubocop-rspec_rails (2.29.1) 248 | rubocop (~> 1.61) 249 | ruby-progressbar (1.13.0) 250 | securerandom (0.4.1) 251 | solid_queue (1.1.3) 252 | activejob (>= 7.1) 253 | activerecord (>= 7.1) 254 | concurrent-ruby (>= 1.3.1) 255 | fugit (~> 1.11.0) 256 | railties (>= 7.1) 257 | thor (~> 1.3.1) 258 | sqlite3 (2.6.0) 259 | mini_portile2 (~> 2.8.0) 260 | sqlite3 (2.6.0-arm64-darwin) 261 | stringio (3.1.5) 262 | thor (1.3.2) 263 | timeout (0.4.3) 264 | tzinfo (2.0.6) 265 | concurrent-ruby (~> 1.0) 266 | unicode-display_width (3.1.4) 267 | unicode-emoji (~> 4.0, >= 4.0.4) 268 | unicode-emoji (4.0.4) 269 | uri (1.0.3) 270 | useragent (0.16.11) 271 | websocket-driver (0.7.7) 272 | base64 273 | websocket-extensions (>= 0.1.0) 274 | websocket-extensions (0.1.5) 275 | zeitwerk (2.7.2) 276 | 277 | PLATFORMS 278 | arm64-darwin-24 279 | ruby 280 | 281 | DEPENDENCIES 282 | factory_bot_rails 283 | rspec-rails 284 | rubocop 285 | rubocop-rails 286 | rubocop-rspec 287 | solid_queue_monitor! 288 | sqlite3 289 | 290 | BUNDLED WITH 291 | 2.6.2 292 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Vishal Sadriya 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SolidQueueMonitor 2 | 3 | [![Gem Version](https://badge.fury.io/rb/solid_queue_monitor.svg)](https://badge.fury.io/rb/solid_queue_monitor) 4 | [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) 5 | 6 | A lightweight, zero-dependency web interface for monitoring Solid Queue background jobs in Ruby on Rails applications. Perfect for Rails API-only applications and traditional Rails apps. 7 | 8 | ## Key Advantages 9 | 10 | - **Works in API-only Rails Applications**: Unlike other monitoring gems that require a full Rails application with asset pipeline or webpacker, SolidQueueMonitor works seamlessly in API-only Rails applications. 11 | - **No External Dependencies**: No JavaScript frameworks, no CSS libraries, no additional gems required - just pure Rails. 12 | - **Self-contained UI**: All HTML, CSS, and JavaScript are generated server-side, making deployment simple and reliable. 13 | - **Minimal Footprint**: Adds minimal overhead to your application while providing powerful monitoring capabilities. 14 | - **Rails 7 Compatible**: Fully compatible with Rails 7.1+ and the latest Solid Queue versions. 15 | 16 | ## Features 17 | 18 | - **Dashboard Overview**: Get a quick snapshot of your queue's health with statistics on all job types 19 | - **Ready Jobs**: View jobs that are ready to be executed 20 | - **In Progress Jobs**: Monitor jobs currently being processed by workers 21 | - **Scheduled Jobs**: See upcoming jobs scheduled for future execution 22 | - **Recurring Jobs**: Manage periodic jobs that run on a schedule 23 | - **Failed Jobs**: Track and debug failed jobs, with the ability to retry or discard them 24 | - **Queue Management**: View and filter jobs by queue 25 | - **Advanced Job Filtering**: Filter jobs by class name, queue, status, and job arguments 26 | - **Quick Actions**: Retry or discard failed jobs directly from any view 27 | - **Performance Optimized**: Designed for high-volume applications with smart pagination 28 | - **Optional Authentication**: Secure your dashboard with HTTP Basic Authentication 29 | - **Responsive Design**: Works on desktop and mobile devices 30 | - **Zero Dependencies**: No additional JavaScript libraries or frameworks required 31 | 32 | ## Screenshots 33 | 34 | ### Dashboard Overview 35 | 36 | ![Dashboard Overview](screenshots/dashboard-3.png) 37 | 38 | ### Failed Jobs 39 | 40 | ![Failed Jobs](screenshots/failed-jobs-2.png) 41 | 42 | ## Installation 43 | 44 | Add this line to your application's Gemfile: 45 | 46 | ```ruby 47 | gem 'solid_queue_monitor', '~> 0.3.1' 48 | ``` 49 | 50 | Then execute: 51 | 52 | ```bash 53 | $ bundle install 54 | ``` 55 | 56 | After bundling, run the generator: 57 | 58 | ```bash 59 | rails generate solid_queue_monitor:install 60 | ``` 61 | 62 | This will: 63 | 64 | 1. Create an initializer at `config/initializers/solid_queue_monitor.rb` 65 | 2. Add required routes to your `config/routes.rb` 66 | 67 | ## Configuration 68 | 69 | You can configure Solid Queue Monitor by editing the initializer: 70 | 71 | ```ruby 72 | # config/initializers/solid_queue_monitor.rb 73 | SolidQueueMonitor.setup do |config| 74 | # Enable or disable authentication 75 | # By default, authentication is disabled for ease of setup 76 | config.authentication_enabled = false 77 | 78 | # Set the username for HTTP Basic Authentication (only used if authentication is enabled) 79 | config.username = 'admin' 80 | 81 | # Set the password for HTTP Basic Authentication (only used if authentication is enabled) 82 | config.password = 'password' 83 | 84 | # Number of jobs to display per page 85 | config.jobs_per_page = 25 86 | end 87 | ``` 88 | 89 | ### Authentication 90 | 91 | By default, Solid Queue Monitor does not require authentication to access the dashboard. This makes it easy to get started in development environments. 92 | 93 | For production environments, it's strongly recommended to enable authentication: 94 | 95 | 1. **Enable authentication**: Set `config.authentication_enabled = true` in the initializer 96 | 2. **Configure secure credentials**: Set `username` and `password` to strong values in the initializer 97 | 98 | ## Usage 99 | 100 | After installation, visit `/solid_queue` in your browser to access the dashboard. 101 | 102 | The dashboard provides several views: 103 | 104 | - **Overview**: Shows statistics and recent jobs 105 | - **Ready Jobs**: Jobs that are ready to be executed 106 | - **Scheduled Jobs**: Jobs scheduled for future execution 107 | - **Recurring Jobs**: Jobs that run on a recurring schedule 108 | - **Failed Jobs**: Jobs that have failed with error details 109 | - **Queues**: Distribution of jobs across different queues 110 | 111 | ### API-only Applications 112 | 113 | For API-only Rails applications, SolidQueueMonitor works out of the box without requiring you to enable the asset pipeline or webpacker. This makes it an ideal choice for monitoring background jobs in modern API-based architectures. 114 | 115 | ### Job Filtering 116 | 117 | You can filter jobs by: 118 | 119 | - **Class Name**: Filter by job class name 120 | - **Queue Name**: Filter by queue name 121 | - **Job Arguments**: Search within job arguments using case-insensitive partial matching 122 | - **Status**: Filter by job status (completed, failed, scheduled, pending) 123 | 124 | This makes it easy to find specific jobs when debugging issues in your application. 125 | 126 | ## Use Cases 127 | 128 | - **Production Monitoring**: Keep an eye on your background job processing in production environments 129 | - **Debugging**: Quickly identify and troubleshoot failed jobs 130 | - **Job Management**: Execute scheduled jobs on demand when needed 131 | - **Performance Analysis**: Track job distribution and identify bottlenecks 132 | - **DevOps Integration**: Easily integrate with your monitoring stack 133 | 134 | ## Compatibility 135 | 136 | - **Ruby**: 3.1.6 or higher 137 | - **Rails**: 7.1 or higher 138 | - **Solid Queue**: 0.1.0 or higher 139 | 140 | ## Contributing 141 | 142 | Contributions are welcome! Here's how you can contribute: 143 | 144 | 1. Fork the repository 145 | 2. Create your feature branch (`git checkout -b my-new-feature`) 146 | 3. Commit your changes (`git commit -am 'Add some feature'`) 147 | 4. Push to the branch (`git push origin my-new-feature`) 148 | 5. Create a new Pull Request 149 | 150 | Please make sure to update tests as appropriate and follow the existing code style. 151 | 152 | ### Development 153 | 154 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 155 | 156 | ## License 157 | 158 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 159 | 160 | ## Code of Conduct 161 | 162 | Everyone interacting in the SolidQueueMonitor project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/yourusername/solid_queue_monitor/blob/main/CODE_OF_CONDUCT.md). 163 | 164 | ## Related Projects 165 | 166 | - [Solid Queue](https://github.com/rails/solid_queue) - The official Rails background job framework 167 | - [Rails](https://github.com/rails/rails) - The web application framework 168 | - [ActiveJob](https://github.com/rails/rails/tree/main/activejob) - Rails job framework 169 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | 10 | namespace :db do 11 | task setup: :environment do 12 | require 'fileutils' 13 | FileUtils.mkdir_p 'spec/dummy/db' 14 | system('cd spec/dummy && bundle exec rails db:environment:set RAILS_ENV=test') 15 | system('cd spec/dummy && bundle exec rails db:schema:load RAILS_ENV=test') 16 | end 17 | end 18 | 19 | task prepare_test_env: :environment do 20 | Rake::Task['db:setup'].invoke 21 | end 22 | 23 | task spec: :prepare_test_env 24 | -------------------------------------------------------------------------------- /app/controllers/solid_queue_monitor/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class ApplicationController < ActionController::Base 5 | include ActionController::HttpAuthentication::Basic::ControllerMethods 6 | include ActionController::Flash 7 | 8 | before_action :authenticate, if: -> { SolidQueueMonitor::AuthenticationService.authentication_required? } 9 | layout false 10 | skip_before_action :verify_authenticity_token 11 | 12 | def set_flash_message(message, type) 13 | session[:flash_message] = message 14 | session[:flash_type] = type 15 | end 16 | 17 | private 18 | 19 | def authenticate 20 | authenticate_or_request_with_http_basic do |username, password| 21 | SolidQueueMonitor::AuthenticationService.authenticate(username, password) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/controllers/solid_queue_monitor/base_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class BaseController < SolidQueueMonitor::ApplicationController 5 | def paginate(relation) 6 | PaginationService.new(relation, current_page, per_page).paginate 7 | end 8 | 9 | def render_page(title, content) 10 | # Get flash message from session 11 | message = session[:flash_message] 12 | message_type = session[:flash_type] 13 | 14 | # Clear the flash message from session after using it 15 | session.delete(:flash_message) 16 | session.delete(:flash_type) 17 | 18 | html = SolidQueueMonitor::HtmlGenerator.new( 19 | title: title, 20 | content: content, 21 | message: message, 22 | message_type: message_type 23 | ).generate 24 | 25 | render html: html.html_safe 26 | end 27 | 28 | def current_page 29 | (params[:page] || 1).to_i 30 | end 31 | 32 | def per_page 33 | SolidQueueMonitor.jobs_per_page 34 | end 35 | 36 | # Preload job statuses to avoid N+1 queries 37 | def preload_job_statuses(jobs) 38 | return if jobs.empty? 39 | 40 | # Get all job IDs 41 | job_ids = jobs.map(&:id) 42 | 43 | # Find all failed jobs in a single query 44 | failed_job_ids = SolidQueue::FailedExecution.where(job_id: job_ids).pluck(:job_id) 45 | 46 | # Find all scheduled jobs in a single query 47 | scheduled_job_ids = SolidQueue::ScheduledExecution.where(job_id: job_ids).pluck(:job_id) 48 | 49 | # Attach the status information to each job 50 | jobs.each do |job| 51 | job.instance_variable_set(:@failed, failed_job_ids.include?(job.id)) 52 | job.instance_variable_set(:@scheduled, scheduled_job_ids.include?(job.id)) 53 | end 54 | 55 | # Define the method to check if a job is failed 56 | SolidQueue::Job.class_eval do 57 | def failed? 58 | if instance_variable_defined?(:@failed) 59 | @failed 60 | else 61 | SolidQueue::FailedExecution.exists?(job_id: id) 62 | end 63 | end 64 | 65 | def scheduled? 66 | if instance_variable_defined?(:@scheduled) 67 | @scheduled 68 | else 69 | SolidQueue::ScheduledExecution.exists?(job_id: id) 70 | end 71 | end 72 | end 73 | end 74 | 75 | def filter_jobs(relation) 76 | relation = relation.where('class_name LIKE ?', "%#{params[:class_name]}%") if params[:class_name].present? 77 | relation = relation.where('queue_name LIKE ?', "%#{params[:queue_name]}%") if params[:queue_name].present? 78 | relation = filter_by_arguments(relation) if params[:arguments].present? 79 | 80 | if params[:status].present? 81 | case params[:status] 82 | when 'completed' 83 | relation = relation.where.not(finished_at: nil) 84 | when 'failed' 85 | failed_job_ids = SolidQueue::FailedExecution.pluck(:job_id) 86 | relation = relation.where(id: failed_job_ids) 87 | when 'scheduled' 88 | scheduled_job_ids = SolidQueue::ScheduledExecution.pluck(:job_id) 89 | relation = relation.where(id: scheduled_job_ids) 90 | when 'pending' 91 | # Pending jobs are those that are not completed, failed, or scheduled 92 | failed_job_ids = SolidQueue::FailedExecution.pluck(:job_id) 93 | scheduled_job_ids = SolidQueue::ScheduledExecution.pluck(:job_id) 94 | relation = relation.where(finished_at: nil) 95 | .where.not(id: failed_job_ids + scheduled_job_ids) 96 | end 97 | end 98 | 99 | relation 100 | end 101 | 102 | def filter_by_arguments(relation) 103 | # Use ILIKE for case-insensitive search in PostgreSQL 104 | relation.where('arguments::text ILIKE ?', "%#{params[:arguments]}%") 105 | end 106 | 107 | def filter_ready_jobs(relation) 108 | return relation unless params[:class_name].present? || params[:queue_name].present? || params[:arguments].present? 109 | 110 | if params[:class_name].present? 111 | job_ids = SolidQueue::Job.where('class_name LIKE ?', "%#{params[:class_name]}%").pluck(:id) 112 | relation = relation.where(job_id: job_ids) 113 | end 114 | 115 | relation = relation.where('queue_name LIKE ?', "%#{params[:queue_name]}%") if params[:queue_name].present? 116 | 117 | # Add arguments filtering 118 | if params[:arguments].present? 119 | job_ids = SolidQueue::Job.where('arguments::text ILIKE ?', "%#{params[:arguments]}%").pluck(:id) 120 | relation = relation.where(job_id: job_ids) 121 | end 122 | 123 | relation 124 | end 125 | 126 | def filter_scheduled_jobs(relation) 127 | return relation unless params[:class_name].present? || params[:queue_name].present? || params[:arguments].present? 128 | 129 | if params[:class_name].present? 130 | job_ids = SolidQueue::Job.where('class_name LIKE ?', "%#{params[:class_name]}%").pluck(:id) 131 | relation = relation.where(job_id: job_ids) 132 | end 133 | 134 | relation = relation.where('queue_name LIKE ?', "%#{params[:queue_name]}%") if params[:queue_name].present? 135 | 136 | # Add arguments filtering 137 | if params[:arguments].present? 138 | job_ids = SolidQueue::Job.where('arguments::text ILIKE ?', "%#{params[:arguments]}%").pluck(:id) 139 | relation = relation.where(job_id: job_ids) 140 | end 141 | 142 | relation 143 | end 144 | 145 | def filter_recurring_jobs(relation) 146 | return relation unless params[:class_name].present? || params[:queue_name].present? || params[:arguments].present? 147 | 148 | relation = relation.where('class_name LIKE ?', "%#{params[:class_name]}%") if params[:class_name].present? 149 | relation = relation.where('queue_name LIKE ?', "%#{params[:queue_name]}%") if params[:queue_name].present? 150 | 151 | # Add arguments filtering if the model has arguments column 152 | if params[:arguments].present? && relation.column_names.include?('arguments') 153 | relation = relation.where('arguments::text ILIKE ?', 154 | "%#{params[:arguments]}%") 155 | end 156 | 157 | relation 158 | end 159 | 160 | def filter_failed_jobs(relation) 161 | return relation unless params[:class_name].present? || params[:queue_name].present? || params[:arguments].present? 162 | 163 | if params[:class_name].present? 164 | job_ids = SolidQueue::Job.where('class_name LIKE ?', "%#{params[:class_name]}%").pluck(:id) 165 | relation = relation.where(job_id: job_ids) 166 | end 167 | 168 | if params[:queue_name].present? 169 | # Check if FailedExecution has queue_name column 170 | if relation.column_names.include?('queue_name') 171 | relation = relation.where('queue_name LIKE ?', "%#{params[:queue_name]}%") 172 | else 173 | # If not, filter by job's queue_name 174 | job_ids = SolidQueue::Job.where('queue_name LIKE ?', "%#{params[:queue_name]}%").pluck(:id) 175 | relation = relation.where(job_id: job_ids) 176 | end 177 | end 178 | 179 | # Add arguments filtering 180 | if params[:arguments].present? 181 | job_ids = SolidQueue::Job.where('arguments::text ILIKE ?', "%#{params[:arguments]}%").pluck(:id) 182 | relation = relation.where(job_id: job_ids) 183 | end 184 | 185 | relation 186 | end 187 | 188 | def filter_params 189 | { 190 | class_name: params[:class_name], 191 | queue_name: params[:queue_name], 192 | arguments: params[:arguments], 193 | status: params[:status] 194 | } 195 | end 196 | end 197 | end 198 | -------------------------------------------------------------------------------- /app/controllers/solid_queue_monitor/failed_jobs_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class FailedJobsController < BaseController 5 | def index 6 | base_query = SolidQueue::FailedExecution.includes(:job).order(created_at: :desc) 7 | @failed_jobs = paginate(filter_failed_jobs(base_query)) 8 | 9 | render_page('Failed Jobs', SolidQueueMonitor::FailedJobsPresenter.new(@failed_jobs[:records], 10 | current_page: @failed_jobs[:current_page], 11 | total_pages: @failed_jobs[:total_pages], 12 | filters: filter_params).render) 13 | end 14 | 15 | def retry 16 | id = params[:id] 17 | service = SolidQueueMonitor::FailedJobService.new 18 | 19 | if service.retry_job(id) 20 | set_flash_message("Job #{id} has been queued for retry.", 'success') 21 | else 22 | set_flash_message("Failed to retry job #{id}.", 'error') 23 | end 24 | 25 | redirect_to(params[:redirect_to].presence || failed_jobs_path) 26 | end 27 | 28 | def discard 29 | id = params[:id] 30 | service = SolidQueueMonitor::FailedJobService.new 31 | 32 | if service.discard_job(id) 33 | set_flash_message("Job #{id} has been discarded.", 'success') 34 | else 35 | set_flash_message("Failed to discard job #{id}.", 'error') 36 | end 37 | 38 | redirect_to(params[:redirect_to].presence || failed_jobs_path) 39 | end 40 | 41 | def retry_all 42 | result = SolidQueueMonitor::FailedJobService.new.retry_all(params[:job_ids]) 43 | 44 | if result[:success] 45 | set_flash_message(result[:message], 'success') 46 | else 47 | set_flash_message(result[:message], 'error') 48 | end 49 | redirect_to failed_jobs_path 50 | end 51 | 52 | def discard_all 53 | result = SolidQueueMonitor::FailedJobService.new.discard_all(params[:job_ids]) 54 | 55 | if result[:success] 56 | set_flash_message(result[:message], 'success') 57 | else 58 | set_flash_message(result[:message], 'error') 59 | end 60 | redirect_to failed_jobs_path 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /app/controllers/solid_queue_monitor/in_progress_jobs_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class InProgressJobsController < BaseController 5 | def index 6 | base_query = SolidQueue::ClaimedExecution.includes(:job).order(created_at: :desc) 7 | @in_progress_jobs = paginate(filter_in_progress_jobs(base_query)) 8 | 9 | render_page('In Progress Jobs', SolidQueueMonitor::InProgressJobsPresenter.new(@in_progress_jobs[:records], 10 | current_page: @in_progress_jobs[:current_page], 11 | total_pages: @in_progress_jobs[:total_pages], 12 | filters: filter_params).render) 13 | end 14 | 15 | private 16 | 17 | def filter_in_progress_jobs(relation) 18 | return relation if params[:class_name].blank? && params[:arguments].blank? 19 | 20 | if params[:class_name].present? 21 | job_ids = SolidQueue::Job.where('class_name LIKE ?', "%#{params[:class_name]}%").pluck(:id) 22 | relation = relation.where(job_id: job_ids) 23 | end 24 | 25 | if params[:arguments].present? 26 | job_ids = SolidQueue::Job.where('arguments::text ILIKE ?', "%#{params[:arguments]}%").pluck(:id) 27 | relation = relation.where(job_id: job_ids) 28 | end 29 | 30 | relation 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /app/controllers/solid_queue_monitor/overview_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class OverviewController < BaseController 5 | def index 6 | @stats = SolidQueueMonitor::StatsCalculator.calculate 7 | 8 | recent_jobs_query = SolidQueue::Job.order(created_at: :desc).limit(100) 9 | @recent_jobs = paginate(filter_jobs(recent_jobs_query)) 10 | 11 | preload_job_statuses(@recent_jobs[:records]) 12 | 13 | render_page('Overview', generate_overview_content) 14 | end 15 | 16 | private 17 | 18 | def generate_overview_content 19 | SolidQueueMonitor::StatsPresenter.new(@stats).render + 20 | SolidQueueMonitor::JobsPresenter.new(@recent_jobs[:records], 21 | current_page: @recent_jobs[:current_page], 22 | total_pages: @recent_jobs[:total_pages], 23 | filters: filter_params).render 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/controllers/solid_queue_monitor/queues_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class QueuesController < BaseController 5 | def index 6 | @queues = SolidQueue::Job.group(:queue_name) 7 | .select('queue_name, COUNT(*) as job_count') 8 | .order('job_count DESC') 9 | 10 | render_page('Queues', SolidQueueMonitor::QueuesPresenter.new(@queues).render) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/controllers/solid_queue_monitor/ready_jobs_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class ReadyJobsController < BaseController 5 | def index 6 | base_query = SolidQueue::ReadyExecution.includes(:job).order(created_at: :desc) 7 | @ready_jobs = paginate(filter_ready_jobs(base_query)) 8 | 9 | render_page('Ready Jobs', SolidQueueMonitor::ReadyJobsPresenter.new(@ready_jobs[:records], 10 | current_page: @ready_jobs[:current_page], 11 | total_pages: @ready_jobs[:total_pages], 12 | filters: filter_params).render) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/controllers/solid_queue_monitor/recurring_jobs_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class RecurringJobsController < BaseController 5 | def index 6 | base_query = filter_recurring_jobs(SolidQueue::RecurringTask.order(:key)) 7 | @recurring_jobs = paginate(base_query) 8 | 9 | render_page('Recurring Jobs', SolidQueueMonitor::RecurringJobsPresenter.new(@recurring_jobs[:records], 10 | current_page: @recurring_jobs[:current_page], 11 | total_pages: @recurring_jobs[:total_pages], 12 | filters: filter_params).render) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/controllers/solid_queue_monitor/scheduled_jobs_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class ScheduledJobsController < BaseController 5 | def index 6 | base_query = SolidQueue::ScheduledExecution.includes(:job).order(scheduled_at: :asc) 7 | @scheduled_jobs = paginate(filter_scheduled_jobs(base_query)) 8 | 9 | render_page('Scheduled Jobs', SolidQueueMonitor::ScheduledJobsPresenter.new(@scheduled_jobs[:records], 10 | current_page: @scheduled_jobs[:current_page], 11 | total_pages: @scheduled_jobs[:total_pages], 12 | filters: filter_params).render) 13 | end 14 | 15 | def create 16 | if params[:job_ids].present? 17 | SolidQueueMonitor::ExecuteJobService.new.execute_many(params[:job_ids]) 18 | set_flash_message('Selected jobs moved to ready queue', 'success') 19 | else 20 | set_flash_message('No jobs selected', 'error') 21 | end 22 | redirect_to scheduled_jobs_path 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/presenters/solid_queue_monitor/base_presenter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class BasePresenter 5 | include ActionView::Helpers::DateHelper 6 | include ActionView::Helpers::TextHelper 7 | include Rails.application.routes.url_helpers 8 | include SolidQueueMonitor::Engine.routes.url_helpers 9 | 10 | def default_url_options 11 | { only_path: true } 12 | end 13 | 14 | def section_wrapper(_title, content) 15 | <<-HTML 16 |
17 |
18 | #{content} 19 |
20 |
21 | HTML 22 | end 23 | 24 | def generate_pagination(current_page, total_pages) 25 | return '' if total_pages <= 1 26 | 27 | html = '' 57 | html 58 | end 59 | 60 | def calculate_visible_pages(current_page, total_pages) 61 | if total_pages <= 7 62 | (1..total_pages).to_a 63 | else 64 | case current_page 65 | when 1..3 66 | [1, 2, 3, 4, :gap, total_pages] 67 | when (total_pages - 2)..total_pages 68 | [1, :gap, total_pages - 3, total_pages - 2, total_pages - 1, total_pages] 69 | else 70 | [1, :gap, current_page - 1, current_page, current_page + 1, :gap, total_pages] 71 | end 72 | end 73 | end 74 | 75 | def format_datetime(datetime) 76 | return '-' unless datetime 77 | 78 | datetime.strftime('%Y-%m-%d %H:%M:%S') 79 | end 80 | 81 | def format_arguments(arguments) 82 | return '-' if arguments.blank? 83 | 84 | # Extract and format the arguments more cleanly 85 | formatted_args = if arguments.is_a?(Hash) && arguments['arguments'].present? 86 | format_job_arguments(arguments) 87 | elsif arguments.is_a?(Array) && arguments.length == 1 && arguments[0].is_a?(Hash) && arguments[0]['arguments'].present? 88 | format_job_arguments(arguments[0]) 89 | else 90 | arguments.inspect 91 | end 92 | 93 | if formatted_args.length <= 50 94 | "#{formatted_args}" 95 | else 96 | <<-HTML 97 |
98 | #{formatted_args} 99 |
100 | HTML 101 | end 102 | end 103 | 104 | def format_hash(hash) 105 | return '-' if hash.blank? 106 | 107 | formatted = hash.map do |key, value| 108 | "#{key}: #{value.to_s.truncate(50)}" 109 | end.join(', ') 110 | 111 | "#{formatted}" 112 | end 113 | 114 | def request_path 115 | if defined?(controller) && controller.respond_to?(:request) 116 | controller.request.path 117 | else 118 | '/solid_queue' 119 | end 120 | end 121 | 122 | def engine_mount_point 123 | path_parts = request_path.split('/') 124 | if path_parts.length >= 3 125 | "/#{path_parts[1]}/#{path_parts[2]}" 126 | else 127 | '/solid_queue' 128 | end 129 | end 130 | 131 | private 132 | 133 | def query_params 134 | params = [] 135 | params << "class_name=#{@filters[:class_name]}" if @filters && @filters[:class_name].present? 136 | params << "queue_name=#{@filters[:queue_name]}" if @filters && @filters[:queue_name].present? 137 | params << "status=#{@filters[:status]}" if @filters && @filters[:status].present? 138 | 139 | params.empty? ? '' : "&#{params.join('&')}" 140 | end 141 | 142 | def full_path(route_name, *args) 143 | SolidQueueMonitor::Engine.routes.url_helpers.send(route_name, *args) 144 | rescue NoMethodError 145 | Rails.application.routes.url_helpers.send("solid_queue_#{route_name}", *args) 146 | end 147 | 148 | def format_job_arguments(job_data) 149 | args = if job_data['arguments'].is_a?(Array) 150 | if job_data['arguments'].first.is_a?(Hash) && job_data['arguments'].first['_aj_ruby2_keywords'].present? 151 | job_data['arguments'].first.except('_aj_ruby2_keywords') 152 | else 153 | job_data['arguments'] 154 | end 155 | else 156 | job_data['arguments'] 157 | end 158 | 159 | args.inspect 160 | end 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /app/presenters/solid_queue_monitor/failed_jobs_presenter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class FailedJobsPresenter < BasePresenter 5 | include Rails.application.routes.url_helpers 6 | include SolidQueueMonitor::Engine.routes.url_helpers 7 | 8 | def initialize(jobs, current_page: 1, total_pages: 1, filters: {}) 9 | @jobs = jobs 10 | @current_page = current_page 11 | @total_pages = total_pages 12 | @filters = filters 13 | end 14 | 15 | def render 16 | section_wrapper('Failed Jobs', 17 | generate_filter_form + generate_table + generate_pagination(@current_page, @total_pages)) 18 | end 19 | 20 | private 21 | 22 | def generate_filter_form 23 | <<-HTML 24 |
25 |
26 |
27 | 28 | 29 |
30 | 31 |
32 | 33 | 34 |
35 | 36 |
37 | 38 | 39 |
40 | 41 |
42 | 43 | Reset 44 |
45 |
46 |
47 | 48 |
49 | 50 | 51 |
52 | HTML 53 | end 54 | 55 | def generate_table 56 | <<-HTML 57 |
58 |
59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | #{@jobs.map { |failed_execution| generate_row(failed_execution) }.join} 72 | 73 |
JobQueueErrorArgumentsActions
74 |
75 |
76 | 77 | 243 | HTML 244 | end 245 | 246 | def generate_row(failed_execution) 247 | job = failed_execution.job 248 | error = parse_error(failed_execution.error) 249 | 250 | <<-HTML 251 | 252 | 253 | 254 |
#{job.class_name}
255 |
256 | Queued at: #{format_datetime(job.created_at)} 257 |
258 | 259 | 260 |
#{job.queue_name}
261 | 262 | 263 |
#{error[:message]}
264 |
265 | Failed at: #{format_datetime(failed_execution.created_at)} 266 |
267 |
268 | Backtrace 269 |
#{error[:backtrace]}
270 |
271 | 272 | #{format_arguments(job.arguments)} 273 | 274 |
275 | Retry 278 | #{' '} 279 | Discard 282 |
283 | 284 | 285 | HTML 286 | end 287 | 288 | def parse_error(error) 289 | return { message: 'Unknown error', backtrace: '' } unless error 290 | 291 | if error.is_a?(String) 292 | { message: error, backtrace: '' } 293 | elsif error.is_a?(Hash) 294 | message = error['message'] || error[:message] || 'Unknown error' 295 | backtrace = error['backtrace'] || error[:backtrace] || [] 296 | backtrace = backtrace.join("\n") if backtrace.is_a?(Array) 297 | { message: message, backtrace: backtrace } 298 | else 299 | { message: 'Unknown error format', backtrace: error.to_s } 300 | end 301 | end 302 | 303 | def get_queue_name(failed_execution, job) 304 | # Try to get queue_name from failed_execution if the method exists 305 | if failed_execution.respond_to?(:queue_name) && !failed_execution.queue_name.nil? 306 | failed_execution.queue_name 307 | else 308 | # Fall back to job's queue_name 309 | job.queue_name 310 | end 311 | rescue NoMethodError 312 | # If there's an error accessing queue_name, fall back to job's queue_name 313 | job.queue_name 314 | end 315 | end 316 | end 317 | -------------------------------------------------------------------------------- /app/presenters/solid_queue_monitor/in_progress_jobs_presenter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class InProgressJobsPresenter < BasePresenter 5 | include SolidQueueMonitor::Engine.routes.url_helpers 6 | 7 | def initialize(jobs, current_page: 1, total_pages: 1, filters: {}) 8 | @jobs = jobs 9 | @current_page = current_page 10 | @total_pages = total_pages 11 | @filters = filters 12 | end 13 | 14 | def render 15 | section_wrapper('In Progress Jobs', 16 | generate_filter_form + generate_table + generate_pagination(@current_page, @total_pages)) 17 | end 18 | 19 | private 20 | 21 | def generate_filter_form 22 | <<-HTML 23 |
24 |
25 |
26 | 27 | 28 |
29 | 30 |
31 | 32 | 33 |
34 | 35 |
36 | 37 | Reset 38 |
39 |
40 |
41 | HTML 42 | end 43 | 44 | def generate_table 45 | <<-HTML 46 |
47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | #{@jobs.map { |execution| generate_row(execution) }.join} 59 | 60 |
JobQueueArgumentsStarted AtProcess ID
61 |
62 | HTML 63 | end 64 | 65 | def generate_row(execution) 66 | job = execution.job 67 | <<-HTML 68 | 69 | 70 |
#{job.class_name}
71 |
72 | Queued at: #{format_datetime(job.created_at)} 73 |
74 | 75 | #{format_arguments(job.arguments)} 76 | #{format_datetime(execution.created_at)} 77 | #{execution.process_id} 78 | 79 | HTML 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /app/presenters/solid_queue_monitor/jobs_presenter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class JobsPresenter < BasePresenter 5 | include Rails.application.routes.url_helpers 6 | include SolidQueueMonitor::Engine.routes.url_helpers 7 | 8 | def initialize(jobs, current_page: 1, total_pages: 1, filters: {}) 9 | @jobs = jobs 10 | @current_page = current_page 11 | @total_pages = total_pages 12 | @filters = filters 13 | end 14 | 15 | def render 16 | <<-HTML 17 |
18 |
19 |

Recent Jobs

20 | #{generate_filter_form} 21 | #{generate_table} 22 | #{generate_pagination(@current_page, @total_pages)} 23 |
24 |
25 | HTML 26 | end 27 | 28 | private 29 | 30 | def generate_filter_form 31 | <<-HTML 32 |
33 |
34 |
35 | 36 | 37 |
38 | 39 |
40 | 41 | 42 |
43 | 44 |
45 | 46 | 47 |
48 | 49 |
50 | 51 | 58 |
59 | 60 |
61 | 62 | Reset 63 |
64 |
65 |
66 | HTML 67 | end 68 | 69 | def generate_table 70 | <<-HTML 71 |
72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | #{@jobs.map { |job| generate_row(job) }.join} 86 | 87 |
IDJobQueueArgumentsStatusCreated AtActions
88 |
89 | HTML 90 | end 91 | 92 | def generate_row(job) 93 | status = job_status(job) 94 | 95 | # Build the row HTML 96 | row_html = <<-HTML 97 | 98 | #{job.id} 99 | #{job.class_name} 100 | #{job.queue_name} 101 | #{format_arguments(job.arguments)} 102 | #{status} 103 | #{format_datetime(job.created_at)} 104 | HTML 105 | 106 | # Add actions column only for failed jobs 107 | if status == 'failed' 108 | # Find the failed execution record for this job 109 | failed_execution = SolidQueue::FailedExecution.find_by(job_id: job.id) 110 | 111 | row_html += if failed_execution 112 | <<-HTML 113 | 114 |
115 |
116 | 117 | 118 |
119 | 120 |
122 | 123 | 124 |
125 |
126 | 127 | HTML 128 | else 129 | '' 130 | end 131 | else 132 | row_html += '' 133 | end 134 | 135 | row_html += '' 136 | row_html 137 | end 138 | 139 | def job_status(job) 140 | SolidQueueMonitor::StatusCalculator.new(job).calculate 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /app/presenters/solid_queue_monitor/queues_presenter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class QueuesPresenter < BasePresenter 5 | def initialize(records) 6 | @records = records 7 | end 8 | 9 | def render 10 | section_wrapper('Queues', generate_table) 11 | end 12 | 13 | private 14 | 15 | def generate_table 16 | <<-HTML 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | #{@records.map { |queue| generate_row(queue) }.join} 30 | 31 |
Queue NameTotal JobsReady JobsScheduled JobsFailed Jobs
32 |
33 | HTML 34 | end 35 | 36 | def generate_row(queue) 37 | <<-HTML 38 | 39 | #{queue.queue_name || 'default'} 40 | #{queue.job_count} 41 | #{ready_jobs_count(queue.queue_name)} 42 | #{scheduled_jobs_count(queue.queue_name)} 43 | #{failed_jobs_count(queue.queue_name)} 44 | 45 | HTML 46 | end 47 | 48 | def ready_jobs_count(queue_name) 49 | SolidQueue::ReadyExecution.where(queue_name: queue_name).count 50 | end 51 | 52 | def scheduled_jobs_count(queue_name) 53 | SolidQueue::ScheduledExecution.where(queue_name: queue_name).count 54 | end 55 | 56 | def failed_jobs_count(queue_name) 57 | SolidQueue::FailedExecution.joins(:job) 58 | .where(solid_queue_jobs: { queue_name: queue_name }) 59 | .count 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /app/presenters/solid_queue_monitor/ready_jobs_presenter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class ReadyJobsPresenter < BasePresenter 5 | def initialize(jobs, current_page: 1, total_pages: 1, filters: {}) 6 | @jobs = jobs 7 | @current_page = current_page 8 | @total_pages = total_pages 9 | @filters = filters 10 | end 11 | 12 | def render 13 | section_wrapper('Ready Jobs', 14 | generate_filter_form + generate_table + generate_pagination(@current_page, @total_pages)) 15 | end 16 | 17 | private 18 | 19 | def generate_filter_form 20 | <<-HTML 21 |
22 |
23 |
24 | 25 | 26 |
27 | 28 |
29 | 30 | 31 |
32 | 33 |
34 | 35 | 36 |
37 | 38 |
39 | 40 | Reset 41 |
42 |
43 |
44 | HTML 45 | end 46 | 47 | def generate_table 48 | <<-HTML 49 |
50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | #{@jobs.map { |execution| generate_row(execution) }.join} 62 | 63 |
JobQueuePriorityArgumentsCreated At
64 |
65 | HTML 66 | end 67 | 68 | def generate_row(execution) 69 | <<-HTML 70 | 71 | #{execution.job.class_name} 72 | #{execution.queue_name} 73 | #{execution.priority} 74 | #{format_arguments(execution.job.arguments)} 75 | #{format_datetime(execution.created_at)} 76 | 77 | HTML 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /app/presenters/solid_queue_monitor/recurring_jobs_presenter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class RecurringJobsPresenter < BasePresenter 5 | include Rails.application.routes.url_helpers 6 | include SolidQueueMonitor::Engine.routes.url_helpers 7 | 8 | def initialize(jobs, current_page: 1, total_pages: 1, filters: {}) 9 | @jobs = jobs 10 | @current_page = current_page 11 | @total_pages = total_pages 12 | @filters = filters 13 | end 14 | 15 | def render 16 | section_wrapper('Recurring Jobs', 17 | generate_filter_form + generate_table + generate_pagination(@current_page, @total_pages)) 18 | end 19 | 20 | private 21 | 22 | def generate_filter_form 23 | <<-HTML 24 |
25 |
26 |
27 | 28 | 29 |
30 | 31 |
32 | 33 | 34 |
35 | 36 |
37 | 38 | Reset 39 |
40 |
41 |
42 | HTML 43 | end 44 | 45 | def generate_table 46 | <<-HTML 47 |
48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | #{@jobs.map { |task| generate_row(task) }.join} 61 | 62 |
KeyJobScheduleQueuePriorityLast Updated
63 |
64 | HTML 65 | end 66 | 67 | def generate_row(task) 68 | <<-HTML 69 | 70 | #{task.key} 71 | #{task.class_name} 72 | #{task.schedule} 73 | #{task.queue_name} 74 | #{task.priority || 'Default'} 75 | #{format_datetime(task.updated_at)} 76 | 77 | HTML 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /app/presenters/solid_queue_monitor/scheduled_jobs_presenter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class ScheduledJobsPresenter < BasePresenter 5 | include Rails.application.routes.url_helpers 6 | include SolidQueueMonitor::Engine.routes.url_helpers 7 | 8 | def initialize(jobs, current_page: 1, total_pages: 1, filters: {}) 9 | @jobs = jobs 10 | @current_page = current_page 11 | @total_pages = total_pages 12 | @filters = filters 13 | end 14 | 15 | def render 16 | section_wrapper('Scheduled Jobs', generate_filter_form + generate_table_with_actions) 17 | end 18 | 19 | private 20 | 21 | def generate_filter_form 22 | <<-HTML 23 |
24 |
25 |
26 | 27 | 28 |
29 | 30 |
31 | 32 | 33 |
34 | 35 |
36 | 37 | 38 |
39 | 40 |
41 | 42 | Reset 43 |
44 |
45 |
46 | 47 |
48 | 49 |
50 | HTML 51 | end 52 | 53 | def generate_table_with_actions 54 | <<-HTML 55 |
56 | #{generate_table} 57 |
58 | 104 | HTML 105 | end 106 | 107 | def generate_table 108 | <<-HTML 109 |
110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | #{@jobs.map { |execution| generate_row(execution) }.join} 122 | 123 |
JobQueueScheduled AtArguments
124 |
125 | #{generate_pagination(@current_page, @total_pages)} 126 | HTML 127 | end 128 | 129 | def generate_row(execution) 130 | <<-HTML 131 | 132 | 133 | 134 | 135 | #{execution.job.class_name} 136 | #{execution.queue_name} 137 | #{format_datetime(execution.scheduled_at)} 138 | #{format_arguments(execution.job.arguments)} 139 | 140 | HTML 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /app/presenters/solid_queue_monitor/stats_presenter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class StatsPresenter < BasePresenter 5 | def initialize(stats) 6 | @stats = stats 7 | end 8 | 9 | def render 10 | <<-HTML 11 |
12 |

Queue Statistics

13 |
14 | #{generate_stat_card('Total Jobs', @stats[:total_jobs])} 15 | #{generate_stat_card('Ready', @stats[:ready])} 16 | #{generate_stat_card('In Progress', @stats[:in_progress])} 17 | #{generate_stat_card('Scheduled', @stats[:scheduled])} 18 | #{generate_stat_card('Recurring', @stats[:recurring])} 19 | #{generate_stat_card('Failed', @stats[:failed])} 20 | #{generate_stat_card('Completed', @stats[:completed])} 21 |
22 |
23 | HTML 24 | end 25 | 26 | private 27 | 28 | def generate_stat_card(title, value) 29 | <<-HTML 30 |
31 |

#{title}

32 |

#{value}

33 |
34 | HTML 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /app/services/solid_queue_monitor/authentication_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class AuthenticationService 5 | def self.authenticate(username, password) 6 | return true unless SolidQueueMonitor.authentication_enabled 7 | 8 | username == SolidQueueMonitor.username && 9 | password == SolidQueueMonitor.password 10 | end 11 | 12 | def self.authentication_required? 13 | SolidQueueMonitor.authentication_enabled 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/services/solid_queue_monitor/execute_job_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class ExecuteJobService 5 | def call(id) 6 | execution = SolidQueue::ScheduledExecution.find(id) 7 | move_to_ready_queue(execution) 8 | end 9 | 10 | def execute_many(ids) 11 | SolidQueue::ScheduledExecution.where(id: ids).find_each do |execution| 12 | move_to_ready_queue(execution) 13 | end 14 | end 15 | 16 | private 17 | 18 | def move_to_ready_queue(execution) 19 | ActiveRecord::Base.transaction do 20 | SolidQueue::ReadyExecution.create!( 21 | job: execution.job, 22 | queue_name: execution.queue_name, 23 | priority: execution.priority 24 | ) 25 | 26 | execution.destroy 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/services/solid_queue_monitor/failed_job_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class FailedJobService 5 | def retry_job(failed_execution_id) 6 | failed_execution = SolidQueue::FailedExecution.find_by(id: failed_execution_id) 7 | return { success: false, message: 'Failed job not found' } unless failed_execution 8 | 9 | job = failed_execution.job 10 | return { success: false, message: 'Associated job not found' } unless job 11 | 12 | ActiveRecord::Base.transaction do 13 | SolidQueue::ReadyExecution.create!( 14 | job_id: job.id, 15 | queue_name: get_queue_name(failed_execution, job), 16 | priority: job.priority 17 | ) 18 | 19 | failed_execution.destroy! 20 | end 21 | 22 | { success: true, message: 'Job moved to ready queue for retry' } 23 | end 24 | 25 | def discard_job(failed_execution_id) 26 | failed_execution = SolidQueue::FailedExecution.find_by(id: failed_execution_id) 27 | return { success: false, message: 'Failed job not found' } unless failed_execution 28 | 29 | job = failed_execution.job 30 | return { success: false, message: 'Associated job not found' } unless job 31 | 32 | ActiveRecord::Base.transaction do 33 | job.update!(finished_at: Time.current) 34 | 35 | failed_execution.destroy! 36 | end 37 | 38 | { success: true, message: 'Job has been discarded' } 39 | end 40 | 41 | def retry_all(job_ids) 42 | return { success: false, message: 'No jobs selected' } if job_ids.blank? 43 | 44 | success_count = 0 45 | failed_count = 0 46 | 47 | job_ids.each do |id| 48 | result = retry_job(id) 49 | if result[:success] 50 | success_count += 1 51 | else 52 | failed_count += 1 53 | end 54 | end 55 | 56 | if success_count.positive? && failed_count.zero? 57 | { success: true, message: 'All selected jobs have been queued for retry' } 58 | elsif success_count.positive? && failed_count.positive? 59 | { success: true, message: "#{success_count} jobs queued for retry, #{failed_count} failed" } 60 | else 61 | { success: false, message: 'Failed to retry jobs' } 62 | end 63 | end 64 | 65 | def discard_all(job_ids) 66 | return { success: false, message: 'No jobs selected' } if job_ids.blank? 67 | 68 | success_count = 0 69 | failed_count = 0 70 | 71 | job_ids.each do |id| 72 | result = discard_job(id) 73 | if result[:success] 74 | success_count += 1 75 | else 76 | failed_count += 1 77 | end 78 | end 79 | 80 | if success_count.positive? && failed_count.zero? 81 | { success: true, message: 'All selected jobs have been discarded' } 82 | elsif success_count.positive? && failed_count.positive? 83 | { success: true, message: "#{success_count} jobs discarded, #{failed_count} failed" } 84 | else 85 | { success: false, message: 'Failed to discard jobs' } 86 | end 87 | end 88 | 89 | private 90 | 91 | def get_queue_name(failed_execution, job) 92 | if failed_execution.respond_to?(:queue_name) && failed_execution.queue_name.present? 93 | failed_execution.queue_name 94 | else 95 | job.queue_name 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /app/services/solid_queue_monitor/html_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class HtmlGenerator 5 | include Rails.application.routes.url_helpers 6 | include SolidQueueMonitor::Engine.routes.url_helpers 7 | 8 | def initialize(title:, content:, message: nil, message_type: nil) 9 | @title = title 10 | @content = content 11 | @message = message 12 | @message_type = message_type 13 | end 14 | 15 | def generate 16 | <<-HTML 17 | 18 | 19 | 20 | Solid Queue Monitor - #{@title} 21 | #{generate_head} 22 | 23 | 24 | #{generate_body} 25 | 26 | 27 | HTML 28 | end 29 | 30 | private 31 | 32 | def generate_head 33 | <<-HTML 34 | 35 | 36 | 39 | HTML 40 | end 41 | 42 | def generate_body 43 | <<-HTML 44 | #{render_message} 45 |
46 | #{generate_header} 47 |
48 |

#{@title}

49 | #{@content} 50 |
51 | #{generate_footer} 52 |
53 | HTML 54 | end 55 | 56 | def render_message 57 | return '' unless @message 58 | 59 | class_name = @message_type == 'success' ? 'message-success' : 'message-error' 60 | <<-HTML 61 |
#{@message}
62 | 85 | HTML 86 | end 87 | 88 | def generate_header 89 | <<-HTML 90 |
91 |

Solid Queue Monitor

92 | 101 |
102 | HTML 103 | end 104 | 105 | def generate_footer 106 | <<-HTML 107 | 110 | HTML 111 | end 112 | 113 | def default_url_options 114 | { only_path: true } 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /app/services/solid_queue_monitor/pagination_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class PaginationService 5 | def initialize(relation, page, per_page) 6 | @relation = relation 7 | @page = page 8 | @per_page = per_page 9 | end 10 | 11 | def paginate 12 | { 13 | records: paginated_records, 14 | total_pages: total_pages, 15 | current_page: @page 16 | } 17 | end 18 | 19 | private 20 | 21 | def offset 22 | (@page - 1) * @per_page 23 | end 24 | 25 | def total_pages 26 | (@relation.count.to_f / @per_page).ceil 27 | end 28 | 29 | def paginated_records 30 | @relation.limit(@per_page).offset(offset) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /app/services/solid_queue_monitor/stats_calculator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class StatsCalculator 5 | def self.calculate 6 | { 7 | total_jobs: SolidQueue::Job.count, 8 | unique_queues: SolidQueue::Job.distinct.count(:queue_name), 9 | scheduled: SolidQueue::ScheduledExecution.count, 10 | ready: SolidQueue::ReadyExecution.count, 11 | failed: SolidQueue::FailedExecution.count, 12 | in_progress: SolidQueue::ClaimedExecution.count, 13 | completed: SolidQueue::Job.where.not(finished_at: nil).count, 14 | recurring: SolidQueue::RecurringTask.count 15 | } 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/services/solid_queue_monitor/status_calculator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class StatusCalculator 5 | def initialize(job) 6 | @job = job 7 | end 8 | 9 | def calculate 10 | return 'completed' if @job.finished_at.present? 11 | return 'failed' if @job.failed? 12 | return 'scheduled' if @job.scheduled_at&.future? 13 | 14 | 'pending' 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/services/solid_queue_monitor/stylesheet_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class StylesheetGenerator 5 | def generate 6 | <<-CSS 7 | .solid_queue_monitor { 8 | --primary-color: #3b82f6; 9 | --success-color: #10b981; 10 | --error-color: #ef4444; 11 | --text-color: #1f2937; 12 | --border-color: #e5e7eb; 13 | --background-color: #f9fafb; 14 | } 15 | 16 | .solid_queue_monitor * { box-sizing: border-box; margin: 0; padding: 0; } 17 | 18 | .solid_queue_monitor { 19 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 20 | line-height: 1.5; 21 | color: var(--text-color); 22 | background: var(--background-color); 23 | } 24 | 25 | .solid_queue_monitor .container { 26 | max-width: 1200px; 27 | margin: 0 auto; 28 | padding: 2rem; 29 | } 30 | 31 | .solid_queue_monitor header { 32 | margin-bottom: 2rem; 33 | text-align: center; 34 | } 35 | 36 | .solid_queue_monitor h1 { 37 | font-size: 2rem; 38 | font-weight: 600; 39 | margin-bottom: 0.5rem; 40 | } 41 | 42 | .solid_queue_monitor .navigation { 43 | display: flex; 44 | flex-wrap: wrap; 45 | justify-content: center; 46 | gap: 0.5rem; 47 | padding: 0.5rem; 48 | } 49 | 50 | .solid_queue_monitor .nav-link { 51 | text-decoration: none; 52 | color: var(--text-color); 53 | padding: 0.5rem 1rem; 54 | border-radius: 0.375rem; 55 | background: white; 56 | box-shadow: 0 1px 3px rgba(0,0,0,0.1); 57 | transition: all 0.2s; 58 | } 59 | 60 | .solid_queue_monitor .nav-link:hover { 61 | background: var(--primary-color); 62 | color: white; 63 | } 64 | 65 | .solid_queue_monitor .section-wrapper { 66 | margin-top: 2rem; 67 | } 68 | 69 | 70 | .solid_queue_monitor .section h2 { 71 | padding: 1rem; 72 | border-bottom: 1px solid var(--border-color); 73 | font-size: 1.25rem; 74 | background: var(--background-color); 75 | } 76 | 77 | .solid_queue_monitor .stats-container { 78 | margin-bottom: 2rem; 79 | } 80 | 81 | .solid_queue_monitor .stats { 82 | display: flex; 83 | flex-direction: row; 84 | flex-wrap: wrap; 85 | gap: 1rem; 86 | margin: 0 -0.5rem; 87 | } 88 | 89 | .solid_queue_monitor .stat-card { 90 | flex: 1 1 0; 91 | min-width: 150px; 92 | background: white; 93 | padding: 1.5rem 1rem; 94 | border-radius: 0.5rem; 95 | box-shadow: 0 1px 3px rgba(0,0,0,0.1); 96 | text-align: center; 97 | } 98 | 99 | .solid_queue_monitor .stat-card h3 { 100 | color: #6b7280; 101 | font-size: 0.875rem; 102 | text-transform: uppercase; 103 | letter-spacing: 0.05em; 104 | margin-bottom: 0.5rem; 105 | } 106 | 107 | .solid_queue_monitor .stat-card p { 108 | font-size: 1.5rem; 109 | font-weight: 600; 110 | color: var(--primary-color); 111 | } 112 | 113 | .solid_queue_monitor .section h2 { 114 | padding: 1rem; 115 | border-bottom: 1px solid var(--border-color); 116 | font-size: 1.25rem; 117 | } 118 | 119 | .solid_queue_monitor .table-container { 120 | width: 100%; 121 | overflow-x: auto; 122 | -webkit-overflow-scrolling: touch; 123 | } 124 | 125 | .solid_queue_monitor table { 126 | width: 100%; 127 | min-width: 800px; /* Ensures table doesn't get too squeezed */ 128 | border-collapse: collapse; 129 | white-space: nowrap; 130 | } 131 | 132 | .solid_queue_monitor th,#{' '} 133 | .solid_queue_monitor td { 134 | padding: 0.75rem 1rem; 135 | text-align: left; 136 | border-bottom: 1px solid var(--border-color); 137 | } 138 | 139 | .solid_queue_monitor th { 140 | background: var(--background-color); 141 | font-weight: 500; 142 | font-size: 0.875rem; 143 | text-transform: uppercase; 144 | letter-spacing: 0.05em; 145 | } 146 | 147 | .solid_queue_monitor .status-badge { 148 | display: inline-block; 149 | padding: 0.25rem 0.5rem; 150 | border-radius: 9999px; 151 | font-size: 0.75rem; 152 | font-weight: 500; 153 | } 154 | 155 | .solid_queue_monitor .table-actions { 156 | display: flex; 157 | justify-content: space-between; 158 | align-items: center; 159 | padding: 1rem; 160 | border-top: 1px solid var(--border-color); 161 | } 162 | 163 | .solid_queue_monitor .select-all { 164 | display: flex; 165 | align-items: center; 166 | gap: 0.5rem; 167 | cursor: pointer; 168 | } 169 | 170 | .solid_queue_monitor .execute-btn:disabled { 171 | opacity: 0.5; 172 | cursor: not-allowed; 173 | } 174 | 175 | .solid_queue_monitor input[type="checkbox"] { 176 | width: 1rem; 177 | height: 1rem; 178 | cursor: pointer; 179 | } 180 | 181 | .solid_queue_monitor .status-completed { background: #d1fae5; color: #065f46; } 182 | .solid_queue_monitor .status-failed { background: #fee2e2; color: #991b1b; } 183 | .solid_queue_monitor .status-scheduled { background: #dbeafe; color: #1e40af; } 184 | .solid_queue_monitor .status-pending { background: #f3f4f6; color: #374151; } 185 | 186 | .solid_queue_monitor .execute-btn { 187 | background: var(--primary-color); 188 | color: white; 189 | border: none; 190 | padding: 0.5rem 1rem; 191 | border-radius: 0.375rem; 192 | font-size: 0.875rem; 193 | cursor: pointer; 194 | transition: background-color 0.2s; 195 | } 196 | 197 | .solid_queue_monitor .execute-btn:hover { 198 | background: #2563eb; 199 | } 200 | 201 | .solid_queue_monitor .message { 202 | padding: 1rem; 203 | margin-bottom: 1rem; 204 | border-radius: 0.375rem; 205 | transition: opacity 0.5s ease-in-out; 206 | } 207 | 208 | .solid_queue_monitor .message-success { 209 | background: #d1fae5; 210 | color: #065f46; 211 | } 212 | 213 | .solid_queue_monitor .message-error { 214 | background: #fee2e2; 215 | color: #991b1b; 216 | } 217 | 218 | .solid_queue_monitor footer { 219 | text-align: center; 220 | padding: 2rem 0; 221 | color: #6b7280; 222 | } 223 | 224 | .solid_queue_monitor .pagination { 225 | display: flex; 226 | justify-content: center; 227 | gap: 0.5rem; 228 | margin-top: 1rem; 229 | padding: 1rem; 230 | } 231 | 232 | .solid_queue_monitor .pagination-nav { 233 | padding: 0.5rem 1rem; 234 | font-size: 0.875rem; 235 | } 236 | #{' '} 237 | .solid_queue_monitor .pagination-gap { 238 | display: inline-flex; 239 | align-items: center; 240 | justify-content: center; 241 | min-width: 2rem; 242 | height: 2rem; 243 | padding: 0 0.5rem; 244 | color: var(--text-color); 245 | } 246 | 247 | .solid_queue_monitor .pagination-link.disabled { 248 | opacity: 0.5; 249 | cursor: not-allowed; 250 | pointer-events: none; 251 | } 252 | 253 | .solid_queue_monitor .pagination-link, 254 | .solid_queue_monitor .pagination-current { 255 | display: inline-flex; 256 | align-items: center; 257 | justify-content: center; 258 | min-width: 2rem; 259 | height: 2rem; 260 | padding: 0 0.5rem; 261 | border-radius: 0.375rem; 262 | font-size: 0.875rem; 263 | text-decoration: none; 264 | transition: all 0.2s; 265 | } 266 | 267 | .solid_queue_monitor .pagination-link { 268 | background: white; 269 | color: var(--text-color); 270 | border: 1px solid var(--border-color); 271 | } 272 | 273 | .solid_queue_monitor .pagination-link:hover { 274 | background: var(--primary-color); 275 | color: white; 276 | border-color: var(--primary-color); 277 | } 278 | 279 | .solid_queue_monitor .pagination-current { 280 | background: var(--primary-color); 281 | color: white; 282 | font-weight: 500; 283 | } 284 | 285 | /* Arguments styling */ 286 | .solid_queue_monitor .args-container { 287 | position: relative; 288 | max-height: 100px; 289 | overflow: hidden; 290 | } 291 | 292 | .solid_queue_monitor .args-content { 293 | display: block; 294 | white-space: pre-wrap; 295 | word-break: break-word; 296 | max-height: 100px; 297 | overflow-y: auto; 298 | padding: 8px; 299 | background: #f5f5f5; 300 | border-radius: 4px; 301 | font-size: 0.9em; 302 | } 303 | 304 | .solid_queue_monitor .args-single-line { 305 | display: inline-block; 306 | padding: 4px 8px; 307 | background: #f5f5f5; 308 | border-radius: 4px; 309 | font-size: 0.9em; 310 | } 311 | 312 | .solid_queue_monitor .args-content::-webkit-scrollbar { 313 | width: 8px; 314 | } 315 | 316 | .solid_queue_monitor .args-content::-webkit-scrollbar-track { 317 | background: #f1f1f1; 318 | border-radius: 4px; 319 | } 320 | 321 | .solid_queue_monitor .args-content::-webkit-scrollbar-thumb { 322 | background: #888; 323 | border-radius: 4px; 324 | } 325 | 326 | .solid_queue_monitor .args-content::-webkit-scrollbar-thumb:hover { 327 | background: #666; 328 | } 329 | 330 | @media (max-width: 768px) { 331 | .solid_queue_monitor .container { 332 | padding: 0.5rem; 333 | } 334 | 335 | .solid_queue_monitor .stats { 336 | margin: 0; 337 | } 338 | 339 | .solid_queue_monitor .stat-card { 340 | flex: 1 1 calc(33.333% - 1rem); 341 | min-width: 120px; 342 | } 343 | 344 | .solid_queue_monitor .section { 345 | margin: 0.5rem 0; 346 | border-radius: 0.375rem; 347 | } 348 | 349 | .solid_queue_monitor .table-container { 350 | width: 100%; 351 | overflow-x: auto; 352 | } 353 | } 354 | 355 | @media (max-width: 480px) { 356 | .solid_queue_monitor .stat-card { 357 | flex: 1 1 calc(50% - 1rem); 358 | } 359 | 360 | .solid_queue_monitor .nav-link { 361 | width: 100%; 362 | text-align: center; 363 | } 364 | .solid_queue_monitor .pagination-nav { 365 | display: none; 366 | } 367 | } 368 | 369 | .solid_queue_monitor .filter-and-actions-container { 370 | display: flex; 371 | justify-content: space-between; 372 | align-items: flex-start; 373 | gap: 1rem; 374 | margin-bottom: 1rem; 375 | } 376 | 377 | .solid_queue_monitor .filter-form-container { 378 | background: white; 379 | padding: 1rem; 380 | border-radius: 0.5rem; 381 | box-shadow: 0 1px 3px rgba(0,0,0,0.1); 382 | flex: 3; 383 | } 384 | 385 | .solid_queue_monitor .bulk-actions-container { 386 | display: flex; 387 | flex-direction: row; 388 | gap: 0.75rem; 389 | padding: 1rem; 390 | background: white; 391 | border-radius: 0.5rem; 392 | box-shadow: 0 1px 3px rgba(0,0,0,0.1); 393 | flex: 2; 394 | align-items: center; 395 | justify-content: center; 396 | } 397 | 398 | .solid_queue_monitor .large-button { 399 | padding: 0.75rem 1.25rem; 400 | font-size: 0.9rem; 401 | text-align: center; 402 | flex: 1; 403 | } 404 | 405 | @media (max-width: 992px) { 406 | .solid_queue_monitor .filter-and-actions-container { 407 | flex-direction: column; 408 | } 409 | #{' '} 410 | .solid_queue_monitor .bulk-actions-container { 411 | width: 100%; 412 | } 413 | } 414 | 415 | .solid_queue_monitor .filter-form { 416 | display: flex; 417 | flex-wrap: wrap; 418 | gap: 1rem; 419 | align-items: flex-end; 420 | } 421 | 422 | .solid_queue_monitor .filter-group { 423 | flex: 1; 424 | min-width: 200px; 425 | } 426 | 427 | .solid_queue_monitor .filter-group label { 428 | display: block; 429 | margin-bottom: 0.5rem; 430 | font-size: 0.875rem; 431 | font-weight: 500; 432 | color: #4b5563; 433 | } 434 | 435 | .solid_queue_monitor .filter-group input, 436 | .solid_queue_monitor .filter-group select { 437 | width: 100%; 438 | padding: 0.5rem; 439 | border: 1px solid #d1d5db; 440 | border-radius: 0.375rem; 441 | font-size: 0.875rem; 442 | } 443 | 444 | .solid_queue_monitor .filter-actions { 445 | display: flex; 446 | gap: 0.5rem; 447 | } 448 | 449 | .solid_queue_monitor .filter-button { 450 | background: var(--primary-color); 451 | color: white; 452 | border: none; 453 | padding: 0.5rem 1rem; 454 | border-radius: 0.375rem; 455 | font-size: 0.875rem; 456 | cursor: pointer; 457 | transition: background-color 0.2s; 458 | } 459 | 460 | .solid_queue_monitor .filter-button:hover { 461 | background: #2563eb; 462 | } 463 | 464 | .solid_queue_monitor .reset-button { 465 | background: #f3f4f6; 466 | color: #4b5563; 467 | border: 1px solid #d1d5db; 468 | padding: 0.5rem 1rem; 469 | border-radius: 0.375rem; 470 | font-size: 0.875rem; 471 | text-decoration: none; 472 | cursor: pointer; 473 | transition: background-color 0.2s; 474 | } 475 | 476 | .solid_queue_monitor .reset-button:hover { 477 | background: #e5e7eb; 478 | } 479 | 480 | .solid_queue_monitor .action-button { 481 | padding: 0.5rem 1rem; 482 | border-radius: 0.375rem; 483 | font-size: 0.75rem; 484 | font-weight: 500; 485 | cursor: pointer; 486 | transition: background-color 0.2s; 487 | border: none; 488 | text-decoration: none; 489 | } 490 | 491 | .solid_queue_monitor .retry-button { 492 | background: #3b82f6; 493 | color: white; 494 | } 495 | 496 | .solid_queue_monitor .retry-button:hover { 497 | background: #2563eb; 498 | } 499 | 500 | .solid_queue_monitor .discard-button { 501 | background: #ef4444; 502 | color: white; 503 | } 504 | 505 | .solid_queue_monitor .discard-button:hover { 506 | background: #dc2626; 507 | } 508 | 509 | .solid_queue_monitor .action-button:disabled { 510 | opacity: 0.5; 511 | cursor: not-allowed; 512 | } 513 | 514 | .solid_queue_monitor .inline-form { 515 | display: inline-block; 516 | margin-right: 0.5rem; 517 | } 518 | 519 | .solid_queue_monitor .actions-cell { 520 | white-space: nowrap; 521 | } 522 | 523 | .solid_queue_monitor .bulk-actions { 524 | display: flex; 525 | gap: 0.5rem; 526 | } 527 | 528 | .solid_queue_monitor .error-message { 529 | color: #dc2626; 530 | font-weight: 500; 531 | margin-bottom: 0.25rem; 532 | } 533 | 534 | .solid_queue_monitor .error-backtrace { 535 | font-size: 0.75rem; 536 | white-space: pre-wrap; 537 | max-height: 200px; 538 | overflow-y: auto; 539 | background: #f3f4f6; 540 | padding: 0.5rem; 541 | border-radius: 0.25rem; 542 | margin-top: 0.5rem; 543 | } 544 | 545 | .solid_queue_monitor details { 546 | margin-top: 0.25rem; 547 | } 548 | 549 | .solid_queue_monitor summary { 550 | cursor: pointer; 551 | color: #6b7280; 552 | font-size: 0.75rem; 553 | } 554 | 555 | .solid_queue_monitor summary:hover { 556 | color: #4b5563; 557 | } 558 | 559 | .solid_queue_monitor .job-checkbox, 560 | .solid_queue_monitor .select-all-checkbox { 561 | width: 1rem; 562 | height: 1rem; 563 | } 564 | 565 | .solid_queue_monitor .bulk-actions-bar { 566 | display: flex; 567 | gap: 0.75rem; 568 | margin: 1rem 0; 569 | background: white; 570 | padding: 0.75rem; 571 | border-radius: 0.5rem; 572 | box-shadow: 0 1px 3px rgba(0,0,0,0.1); 573 | } 574 | 575 | .solid_queue_monitor .bulk-actions-bar .action-button { 576 | padding: 0.6rem 1rem; 577 | font-size: 0.875rem; 578 | } 579 | 580 | .solid_queue_monitor .execute-button { 581 | background: var(--primary-color); 582 | color: white; 583 | } 584 | 585 | .solid_queue_monitor .execute-button:hover { 586 | background: #2563eb; 587 | } 588 | CSS 589 | end 590 | end 591 | end 592 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "solid_queue_monitor" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require "irb" 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path("..", __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | puts "== Installing dependencies ==" 13 | system! "gem install bundler --conservative" 14 | system("bundle check") || system!("bundle install") 15 | 16 | puts "\n== Preparing database ==" 17 | system! "cd spec/dummy && bin/rails db:test:prepare" 18 | 19 | puts "\n== Removing old logs and tempfiles ==" 20 | system! "rm -f spec/dummy/log/*" 21 | system! "rm -rf spec/dummy/tmp/cache" 22 | 23 | puts "\n== All set! ==" 24 | end -------------------------------------------------------------------------------- /config/initializers/solid_queue_monitor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SolidQueueMonitor.setup do |config| 4 | config.username = 'admin' # Change this in your application 5 | config.password = 'password' # Change this in your application 6 | config.jobs_per_page = 25 7 | end 8 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SolidQueueMonitor::Engine.routes.draw do 4 | root to: 'overview#index', as: :root 5 | 6 | resources :ready_jobs, only: [:index] 7 | resources :scheduled_jobs, only: [:index] 8 | resources :recurring_jobs, only: [:index] 9 | resources :failed_jobs, only: [:index] 10 | resources :in_progress_jobs, only: [:index] 11 | resources :queues, only: [:index] 12 | 13 | post 'execute_jobs', to: 'scheduled_jobs#create', as: :execute_jobs 14 | 15 | post 'retry_failed_job/:id', to: 'failed_jobs#retry', as: :retry_failed_job 16 | post 'discard_failed_job/:id', to: 'failed_jobs#discard', as: :discard_failed_job 17 | post 'retry_failed_jobs', to: 'failed_jobs#retry_all', as: :retry_failed_jobs 18 | post 'discard_failed_jobs', to: 'failed_jobs#discard_all', as: :discard_failed_jobs 19 | end 20 | -------------------------------------------------------------------------------- /lib/generators/solid_queue_monitor/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators/base' 4 | 5 | module SolidQueueMonitor 6 | module Generators 7 | class InstallGenerator < Rails::Generators::Base 8 | source_root File.expand_path('templates', __dir__) 9 | 10 | def copy_initializer 11 | template 'initializer.rb', 'config/initializers/solid_queue_monitor.rb' 12 | end 13 | 14 | def add_routes 15 | prepend_to_file 'config/routes.rb', "require 'solid_queue_monitor'\n\n" 16 | 17 | route "mount SolidQueueMonitor::Engine => '/solid_queue'" 18 | end 19 | 20 | def show_readme 21 | readme 'README.md' if behavior == :invoke 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/generators/solid_queue_monitor/templates/README.md: -------------------------------------------------------------------------------- 1 | # SolidQueueMonitor Installation 2 | 3 | The SolidQueueMonitor has been installed. 4 | 5 | ## Next Steps 6 | 7 | 1. Configure your settings in `config/initializers/solid_queue_monitor.rb` 8 | 9 | 2. Access your dashboard at: http://your-app-url/solid_queue 10 | 11 | 3. Authentication: 12 | - Authentication is disabled by default for ease of setup 13 | - To enable authentication, set `config.authentication_enabled = true` in the initializer 14 | - Default credentials (when authentication is enabled): 15 | - Username: admin 16 | - Password: password 17 | 18 | ## Security Note 19 | 20 | For production environments, it's strongly recommended to: 21 | 22 | 1. Enable authentication 23 | 2. Change the default credentials to secure values 24 | -------------------------------------------------------------------------------- /lib/generators/solid_queue_monitor/templates/initializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SolidQueueMonitor.setup do |config| 4 | # Enable or disable authentication 5 | # When disabled, no authentication is required to access the monitor 6 | config.authentication_enabled = false 7 | 8 | # Set the username for HTTP Basic Authentication (only used if authentication is enabled) 9 | # config.username = 'admin' 10 | 11 | # Set the password for HTTP Basic Authentication (only used if authentication is enabled) 12 | # config.password = 'password' 13 | 14 | # Number of jobs to display per page 15 | # config.jobs_per_page = 25 16 | end 17 | -------------------------------------------------------------------------------- /lib/solid_queue_monitor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'solid_queue_monitor/version' 4 | require_relative 'solid_queue_monitor/engine' 5 | 6 | module SolidQueueMonitor 7 | class Error < StandardError; end 8 | class << self 9 | attr_accessor :username, :password, :jobs_per_page, :authentication_enabled 10 | end 11 | 12 | @username = 'admin' 13 | @password = 'password' 14 | @jobs_per_page = 25 15 | @authentication_enabled = false 16 | 17 | def self.setup 18 | yield self 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/solid_queue_monitor/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | class Engine < ::Rails::Engine 5 | isolate_namespace SolidQueueMonitor 6 | 7 | config.autoload_paths << root.join('app', 'services') 8 | 9 | # Optional: Add eager loading for production 10 | config.eager_load_paths << root.join('app', 'services') 11 | 12 | initializer 'solid_queue_monitor.assets' do |app| 13 | # Optional: Add assets if needed 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/solid_queue_monitor/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidQueueMonitor 4 | VERSION = '0.3.1' 5 | end 6 | -------------------------------------------------------------------------------- /lib/tasks/app.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :app do 4 | desc 'Setup the dummy app for testing' 5 | task setup: :environment do 6 | require 'fileutils' 7 | 8 | # Create dummy app directories 9 | dummy_app_path = File.expand_path('../spec/dummy', __dir__) 10 | 11 | # Ensure directories exist 12 | %w[ 13 | app/controllers 14 | app/models 15 | app/views 16 | config/environments 17 | config/initializers 18 | db 19 | lib 20 | log 21 | ].each do |dir| 22 | FileUtils.mkdir_p(File.join(dummy_app_path, dir)) 23 | end 24 | 25 | # Create necessary files if they don't exist 26 | unless File.exist?(File.join(dummy_app_path, 'config/boot.rb')) 27 | File.write(File.join(dummy_app_path, 'config/boot.rb'), <<~RUBY) 28 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__) 29 | require 'bundler/setup' 30 | RUBY 31 | end 32 | 33 | unless File.exist?(File.join(dummy_app_path, 'config/application.rb')) 34 | File.write(File.join(dummy_app_path, 'config/application.rb'), <<~RUBY) 35 | require_relative "boot" 36 | 37 | require "rails" 38 | require "active_model/railtie" 39 | require "active_record/railtie" 40 | require "action_controller/railtie" 41 | require "action_view/railtie" 42 | require "rails/test_unit/railtie" 43 | require "solid_queue" 44 | require "solid_queue_monitor" 45 | 46 | module Dummy 47 | class Application < Rails::Application 48 | config.load_defaults Rails::VERSION::STRING.to_f 49 | #{' '} 50 | # Settings in config/environments/* take precedence over those specified here. 51 | # Application configuration can go into files in config/initializers 52 | # -- all .rb files in that directory are automatically loaded after loading 53 | # the framework and any gems in your application. 54 | #{' '} 55 | # Only loads a smaller set of middleware suitable for API only apps. 56 | # Middleware like session, flash, cookies can be added back manually. 57 | config.api_only = true 58 | #{' '} 59 | # Don't generate system test files. 60 | config.generators.system_tests = nil 61 | end 62 | end 63 | RUBY 64 | end 65 | 66 | unless File.exist?(File.join(dummy_app_path, 'config/environment.rb')) 67 | File.write(File.join(dummy_app_path, 'config/environment.rb'), <<~RUBY) 68 | # Load the Rails application. 69 | require_relative 'application' 70 | 71 | # Initialize the Rails application. 72 | Rails.application.initialize! 73 | RUBY 74 | end 75 | 76 | unless File.exist?(File.join(dummy_app_path, 'config/environments/test.rb')) 77 | File.write(File.join(dummy_app_path, 'config/environments/test.rb'), <<~RUBY) 78 | Rails.application.configure do 79 | # Settings specified here will take precedence over those in config/application.rb. 80 | 81 | # The test environment is used exclusively to run your application's 82 | # test suite. You never need to work with it otherwise. Remember that 83 | # your test database is "scratch space" for the test suite and is wiped 84 | # and recreated between test runs. Don't rely on the data there! 85 | config.cache_classes = true 86 | 87 | # Do not eager load code on boot. This avoids loading your whole application 88 | # just for the purpose of running a single test. If you are using a tool that 89 | # preloads Rails for running tests, you may have to set it to true. 90 | config.eager_load = false 91 | 92 | # Configure public file server for tests with Cache-Control for performance. 93 | config.public_file_server.enabled = true 94 | config.public_file_server.headers = { 95 | 'Cache-Control' => "public, max-age=\#{1.hour.to_i}" 96 | } 97 | 98 | # Show full error reports and disable caching. 99 | config.consider_all_requests_local = true 100 | config.action_controller.perform_caching = false 101 | 102 | # Raise exceptions instead of rendering exception templates. 103 | config.action_dispatch.show_exceptions = false 104 | 105 | # Disable request forgery protection in test environment. 106 | config.action_controller.allow_forgery_protection = false 107 | 108 | # Print deprecation notices to the stderr. 109 | config.active_support.deprecation = :stderr 110 | 111 | # Raises error for missing translations. 112 | # config.action_view.raise_on_missing_translations = true 113 | end 114 | RUBY 115 | end 116 | 117 | unless File.exist?(File.join(dummy_app_path, 'config/database.yml')) 118 | File.write(File.join(dummy_app_path, 'config/database.yml'), <<~YAML) 119 | test: 120 | adapter: sqlite3 121 | database: ":memory:" 122 | pool: 5 123 | timeout: 5000 124 | YAML 125 | end 126 | 127 | unless File.exist?(File.join(dummy_app_path, 'config/routes.rb')) 128 | File.write(File.join(dummy_app_path, 'config/routes.rb'), <<~RUBY) 129 | Rails.application.routes.draw do 130 | mount SolidQueueMonitor::Engine => "/solid_queue" 131 | end 132 | RUBY 133 | end 134 | 135 | puts 'Dummy app setup complete!' 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /log/test.log: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vishaltps/solid_queue_monitor/2ed8c23b353e8f960657e52b5a67e31407ad70b5/log/test.log -------------------------------------------------------------------------------- /screenshots/.gitkeep: -------------------------------------------------------------------------------- 1 | # This file ensures the screenshots directory is tracked by Git 2 | # You can delete this file once you've added actual screenshots -------------------------------------------------------------------------------- /screenshots/dashboard-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vishaltps/solid_queue_monitor/2ed8c23b353e8f960657e52b5a67e31407ad70b5/screenshots/dashboard-2.png -------------------------------------------------------------------------------- /screenshots/dashboard-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vishaltps/solid_queue_monitor/2ed8c23b353e8f960657e52b5a67e31407ad70b5/screenshots/dashboard-3.png -------------------------------------------------------------------------------- /screenshots/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vishaltps/solid_queue_monitor/2ed8c23b353e8f960657e52b5a67e31407ad70b5/screenshots/dashboard.png -------------------------------------------------------------------------------- /screenshots/failed-jobs-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vishaltps/solid_queue_monitor/2ed8c23b353e8f960657e52b5a67e31407ad70b5/screenshots/failed-jobs-2.png -------------------------------------------------------------------------------- /screenshots/failed_jobs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vishaltps/solid_queue_monitor/2ed8c23b353e8f960657e52b5a67e31407ad70b5/screenshots/failed_jobs.png -------------------------------------------------------------------------------- /screenshots/recurring_jobs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vishaltps/solid_queue_monitor/2ed8c23b353e8f960657e52b5a67e31407ad70b5/screenshots/recurring_jobs.png -------------------------------------------------------------------------------- /sig/solid_queue_monitor.rbs: -------------------------------------------------------------------------------- 1 | module SolidQueueMonitor 2 | VERSION: String 3 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 4 | end 5 | -------------------------------------------------------------------------------- /solid_queue_monitor.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/solid_queue_monitor/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'solid_queue_monitor' 7 | spec.version = SolidQueueMonitor::VERSION 8 | spec.authors = ['Vishal Sadriya'] 9 | spec.email = ['vishalsadriya1224@gmail.com'] 10 | 11 | spec.summary = 'Simple monitoring interface for Solid Queue' 12 | spec.description = 'A lightweight, zero-dependency web interface for monitoring Solid Queue jobs in Rails applications' 13 | spec.homepage = 'https://github.com/vishaltps/solid_queue_monitor' 14 | spec.license = 'MIT' 15 | spec.required_ruby_version = '>= 3.0.0' 16 | 17 | spec.metadata['allowed_push_host'] = 'https://rubygems.org' 18 | spec.metadata['homepage_uri'] = spec.homepage 19 | spec.metadata['source_code_uri'] = spec.homepage 20 | spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md" 21 | spec.metadata['rubygems_mfa_required'] = 'true' 22 | 23 | spec.files = Dir['{app,config,lib}/**/*', 'LICENSE', 'Rakefile', 'README.md'] 24 | spec.require_paths = ['lib'] 25 | 26 | spec.add_dependency 'rails', '>= 7.0' 27 | spec.add_dependency 'solid_queue', '>= 0.1.0' 28 | end 29 | -------------------------------------------------------------------------------- /spec/controllers/solid_queue_monitor/failed_jobs_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module SolidQueueMonitor 6 | RSpec.describe FailedJobsController do 7 | routes { SolidQueueMonitor::Engine.routes } 8 | 9 | let(:valid_credentials) { ActionController::HttpAuthentication::Basic.encode_credentials('admin', 'password123') } 10 | 11 | before do 12 | # Skip authentication for tests by default 13 | allow(SolidQueueMonitor::AuthenticationService).to receive(:authentication_required?).and_return(false) 14 | end 15 | 16 | describe 'GET #index' do 17 | let!(:failed_job1) { create(:solid_queue_failed_execution, created_at: 1.hour.ago) } 18 | let!(:failed_job2) { create(:solid_queue_failed_execution, created_at: 2.hours.ago) } 19 | 20 | it 'returns a successful response' do 21 | get :index 22 | expect(response).to be_successful 23 | end 24 | 25 | it 'assigns failed jobs ordered by created_at desc' do 26 | get :index 27 | expect(assigns(:failed_jobs)[:records]).to eq([failed_job1, failed_job2]) 28 | end 29 | 30 | context 'with filters' do 31 | let!(:special_job) do 32 | job = create(:solid_queue_job, class_name: 'SpecialJob', queue_name: 'high_priority') 33 | create(:solid_queue_failed_execution, job: job) 34 | end 35 | 36 | it 'filters by class name' do 37 | get :index, params: { class_name: 'Special' } 38 | expect(assigns(:failed_jobs)[:records]).to eq([special_job]) 39 | end 40 | 41 | it 'filters by queue name' do 42 | get :index, params: { queue_name: 'high' } 43 | expect(assigns(:failed_jobs)[:records]).to eq([special_job]) 44 | end 45 | end 46 | 47 | context 'with pagination' do 48 | before do 49 | allow(SolidQueueMonitor).to receive(:jobs_per_page).and_return(1) 50 | end 51 | 52 | it 'paginates the results' do 53 | get :index, params: { page: 2 } 54 | expect(assigns(:failed_jobs)[:records]).to eq([failed_job2]) 55 | expect(assigns(:failed_jobs)[:total_pages]).to eq(2) 56 | expect(assigns(:failed_jobs)[:current_page]).to eq(2) 57 | end 58 | end 59 | end 60 | 61 | describe 'POST #retry' do 62 | let!(:failed_job) { create(:solid_queue_failed_execution) } 63 | let(:service) { instance_double(SolidQueueMonitor::FailedJobService) } 64 | 65 | before do 66 | allow(SolidQueueMonitor::FailedJobService).to receive(:new).and_return(service) 67 | end 68 | 69 | context 'when retry is successful' do 70 | before do 71 | allow(service).to receive(:retry_job).with(failed_job.id.to_s).and_return(true) 72 | end 73 | 74 | it 'sets success flash message and redirects' do 75 | post :retry, params: { id: failed_job.id } 76 | 77 | expect(session[:flash_message]).to eq("Job #{failed_job.id} has been queued for retry.") 78 | expect(session[:flash_type]).to eq('success') 79 | expect(response).to redirect_to(failed_jobs_path) 80 | end 81 | 82 | it 'respects custom redirect path' do 83 | post :retry, params: { id: failed_job.id, redirect_to: '/custom/path' } 84 | expect(response).to redirect_to('/custom/path') 85 | end 86 | end 87 | 88 | context 'when retry fails' do 89 | before do 90 | allow(service).to receive(:retry_job).with(failed_job.id.to_s).and_return(false) 91 | end 92 | 93 | it 'sets error flash message and redirects' do 94 | post :retry, params: { id: failed_job.id } 95 | 96 | expect(session[:flash_message]).to eq("Failed to retry job #{failed_job.id}.") 97 | expect(session[:flash_type]).to eq('error') 98 | expect(response).to redirect_to(failed_jobs_path) 99 | end 100 | end 101 | end 102 | 103 | describe 'POST #discard' do 104 | let!(:failed_job) { create(:solid_queue_failed_execution) } 105 | let(:service) { instance_double(SolidQueueMonitor::FailedJobService) } 106 | 107 | before do 108 | allow(SolidQueueMonitor::FailedJobService).to receive(:new).and_return(service) 109 | end 110 | 111 | context 'when discard is successful' do 112 | before do 113 | allow(service).to receive(:discard_job).with(failed_job.id.to_s).and_return(true) 114 | end 115 | 116 | it 'sets success flash message and redirects' do 117 | post :discard, params: { id: failed_job.id } 118 | 119 | expect(session[:flash_message]).to eq("Job #{failed_job.id} has been discarded.") 120 | expect(session[:flash_type]).to eq('success') 121 | expect(response).to redirect_to(failed_jobs_path) 122 | end 123 | 124 | it 'respects custom redirect path' do 125 | post :discard, params: { id: failed_job.id, redirect_to: '/custom/path' } 126 | expect(response).to redirect_to('/custom/path') 127 | end 128 | end 129 | 130 | context 'when discard fails' do 131 | before do 132 | allow(service).to receive(:discard_job).with(failed_job.id.to_s).and_return(false) 133 | end 134 | 135 | it 'sets error flash message and redirects' do 136 | post :discard, params: { id: failed_job.id } 137 | 138 | expect(session[:flash_message]).to eq("Failed to discard job #{failed_job.id}.") 139 | expect(session[:flash_type]).to eq('error') 140 | expect(response).to redirect_to(failed_jobs_path) 141 | end 142 | end 143 | end 144 | 145 | describe 'POST #retry_all' do 146 | let(:job_ids) { %w[1 2 3] } 147 | let(:service) { instance_double(SolidQueueMonitor::FailedJobService) } 148 | 149 | before do 150 | allow(SolidQueueMonitor::FailedJobService).to receive(:new).and_return(service) 151 | end 152 | 153 | context 'when retry_all is successful' do 154 | before do 155 | allow(service).to receive(:retry_all).with(job_ids).and_return({ success: true, message: 'All jobs queued for retry' }) 156 | end 157 | 158 | it 'sets success flash message and redirects' do 159 | post :retry_all, params: { job_ids: job_ids } 160 | 161 | expect(session[:flash_message]).to eq('All jobs queued for retry') 162 | expect(session[:flash_type]).to eq('success') 163 | expect(response).to redirect_to(failed_jobs_path) 164 | end 165 | end 166 | 167 | context 'when retry_all fails' do 168 | before do 169 | allow(service).to receive(:retry_all).with(job_ids).and_return({ success: false, message: 'Failed to retry jobs' }) 170 | end 171 | 172 | it 'sets error flash message and redirects' do 173 | post :retry_all, params: { job_ids: job_ids } 174 | 175 | expect(session[:flash_message]).to eq('Failed to retry jobs') 176 | expect(session[:flash_type]).to eq('error') 177 | expect(response).to redirect_to(failed_jobs_path) 178 | end 179 | end 180 | end 181 | 182 | describe 'POST #discard_all' do 183 | let(:job_ids) { %w[1 2 3] } 184 | let(:service) { instance_double(SolidQueueMonitor::FailedJobService) } 185 | 186 | before do 187 | allow(SolidQueueMonitor::FailedJobService).to receive(:new).and_return(service) 188 | end 189 | 190 | context 'when discard_all is successful' do 191 | before do 192 | allow(service).to receive(:discard_all).with(job_ids).and_return({ success: true, message: 'All jobs discarded' }) 193 | end 194 | 195 | it 'sets success flash message and redirects' do 196 | post :discard_all, params: { job_ids: job_ids } 197 | 198 | expect(session[:flash_message]).to eq('All jobs discarded') 199 | expect(session[:flash_type]).to eq('success') 200 | expect(response).to redirect_to(failed_jobs_path) 201 | end 202 | end 203 | 204 | context 'when discard_all fails' do 205 | before do 206 | allow(service).to receive(:discard_all).with(job_ids).and_return({ success: false, message: 'Failed to discard jobs' }) 207 | end 208 | 209 | it 'sets error flash message and redirects' do 210 | post :discard_all, params: { job_ids: job_ids } 211 | 212 | expect(session[:flash_message]).to eq('Failed to discard jobs') 213 | expect(session[:flash_type]).to eq('error') 214 | expect(response).to redirect_to(failed_jobs_path) 215 | end 216 | end 217 | end 218 | 219 | context 'with authentication required' do 220 | before do 221 | allow(SolidQueueMonitor::AuthenticationService).to receive_messages(authentication_required?: true, authenticate: true) 222 | end 223 | 224 | it 'requires authentication for index' do 225 | get :index 226 | expect(response).to have_http_status(:unauthorized) 227 | end 228 | 229 | it 'allows access with valid credentials' do 230 | request.env['HTTP_AUTHORIZATION'] = valid_credentials 231 | get :index 232 | expect(response).to be_successful 233 | end 234 | end 235 | end 236 | end 237 | -------------------------------------------------------------------------------- /spec/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative 'config/application' 5 | 6 | Rails.application.load_tasks -------------------------------------------------------------------------------- /spec/dummy/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'boot' 4 | 5 | require 'rails' 6 | require 'active_model/railtie' 7 | require 'active_record/railtie' 8 | require 'action_controller/railtie' 9 | require 'solid_queue' 10 | require 'solid_queue_monitor' 11 | 12 | module Dummy 13 | class Application < Rails::Application 14 | config.load_defaults Rails::VERSION::STRING.to_f 15 | 16 | # For Rails 7+ 17 | config.active_job.queue_adapter = :solid_queue 18 | 19 | # Prevent deprecation warnings 20 | config.active_support.deprecation = :log 21 | config.eager_load = false 22 | 23 | # Database configuration 24 | config.active_record.sqlite3.represent_boolean_as_integer = true 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'boot' 4 | 5 | require 'rails' 6 | # Pick the frameworks you want: 7 | require 'active_model/railtie' 8 | require 'active_job/railtie' 9 | require 'active_record/railtie' 10 | # require "active_storage/engine" 11 | require 'action_controller/railtie' 12 | # require "action_mailer/railtie" 13 | # require "action_mailbox/engine" 14 | # require "action_text/engine" 15 | require 'action_view/railtie' 16 | # require "action_cable/engine" 17 | # require "rails/test_unit/railtie" 18 | 19 | # Require the gems listed in Gemfile, including any gems 20 | # you've limited to :test, :development, or :production. 21 | Bundler.require(*Rails.groups) 22 | require 'solid_queue_monitor' 23 | 24 | module Dummy 25 | class Application < Rails::Application 26 | # Initialize configuration defaults for originally generated Rails version. 27 | config.load_defaults 7.0 28 | 29 | # Configuration for the application, engines, and railties goes here. 30 | # 31 | # These settings can be overridden in specific environments using the files 32 | # in config/environments, which are processed later. 33 | # 34 | # config.time_zone = "Central Time (US & Canada)" 35 | # config.eager_load_paths << Rails.root.join("extras") 36 | 37 | # Don't generate system test files. 38 | config.generators.system_tests = nil 39 | 40 | # Set eager_load to false for test environment 41 | config.eager_load = false if Rails.env.test? 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__) 4 | 5 | require 'bundler/setup' # Set up gems listed in the Gemfile. 6 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Load the Rails application. 4 | require_relative 'application' 5 | 6 | # Initialize the Rails application. 7 | Rails.application.initialize! 8 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/integer/time' 4 | 5 | Rails.application.configure do 6 | # Settings specified here will take precedence over those in config/application.rb. 7 | 8 | # In the development environment your application's code is reloaded any time 9 | # it changes. This slows down response time but is perfect for development 10 | # since you don't have to restart the web server when you make code changes. 11 | config.cache_classes = false 12 | 13 | # Do not eager load code on boot. 14 | config.eager_load = false 15 | 16 | # Show full error reports. 17 | config.consider_all_requests_local = true 18 | 19 | # Enable server timing 20 | config.server_timing = true 21 | 22 | # Enable/disable caching. By default caching is disabled. 23 | # Run rails dev:cache to toggle caching. 24 | if Rails.root.join('tmp/caching-dev.txt').exist? 25 | config.action_controller.perform_caching = true 26 | config.action_controller.enable_fragment_cache_logging = true 27 | 28 | config.cache_store = :memory_store 29 | config.public_file_server.headers = { 30 | 'Cache-Control' => "public, max-age=#{2.days.to_i}" 31 | } 32 | else 33 | config.action_controller.perform_caching = false 34 | 35 | config.cache_store = :null_store 36 | end 37 | 38 | # Print deprecation notices to the Rails logger. 39 | config.active_support.deprecation = :log 40 | 41 | # Raise exceptions for disallowed deprecations. 42 | config.active_support.disallowed_deprecation = :raise 43 | 44 | # Tell Active Support which deprecation messages to disallow. 45 | config.active_support.disallowed_deprecation_warnings = [] 46 | 47 | # Raise an error on page load if there are pending migrations. 48 | config.active_record.migration_error = :page_load 49 | 50 | # Highlight code that triggered database queries in logs. 51 | config.active_record.verbose_query_logs = true 52 | 53 | # Suppress logger output for asset requests. 54 | config.assets.quiet = true 55 | 56 | # Raises error for missing translations. 57 | # config.i18n.raise_on_missing_translations = true 58 | 59 | # Annotate rendered view with file names. 60 | # config.action_view.annotate_rendered_view_with_filenames = true 61 | 62 | # Uncomment if you wish to allow Action Cable access from any origin. 63 | # config.action_cable.disable_request_forgery_protection = true 64 | end 65 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/integer/time' 4 | 5 | # The test environment is used exclusively to run your application's 6 | # test suite. You never need to work with it otherwise. Remember that 7 | # your test database is "scratch space" for the test suite and is wiped 8 | # and recreated between test runs. Don't rely on the data there! 9 | 10 | Rails.application.configure do 11 | # Settings specified here will take precedence over those in config/application.rb. 12 | 13 | # Turn false under Spring and add config.action_view.cache_template_loading = true. 14 | config.cache_classes = true 15 | 16 | # Eager loading loads your whole application. When running a single test locally, 17 | # this probably isn't necessary. It's a good idea to do in a CI environment, 18 | # or in some way before running all the tests. 19 | config.eager_load = false 20 | 21 | # Configure public file server for tests with Cache-Control for performance. 22 | config.public_file_server.enabled = true 23 | config.public_file_server.headers = { 24 | 'Cache-Control' => "public, max-age=#{1.hour.to_i}" 25 | } 26 | 27 | # Show full error reports and disable caching. 28 | config.consider_all_requests_local = true 29 | config.action_controller.perform_caching = false 30 | config.cache_store = :null_store 31 | 32 | # Raise exceptions instead of rendering exception templates. 33 | config.action_dispatch.show_exceptions = false 34 | 35 | # Disable request forgery protection in test environment. 36 | config.action_controller.allow_forgery_protection = false 37 | 38 | # Print deprecation notices to the stderr. 39 | config.active_support.deprecation = :stderr 40 | 41 | # Raise exceptions for disallowed deprecations. 42 | config.active_support.disallowed_deprecation = :raise 43 | 44 | # Tell Active Support which deprecation messages to disallow. 45 | config.active_support.disallowed_deprecation_warnings = [] 46 | 47 | # Raises error for missing translations. 48 | # config.i18n.raise_on_missing_translations = true 49 | 50 | # Annotate rendered view with file names. 51 | # config.action_view.annotate_rendered_view_with_filenames = true 52 | end 53 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | mount SolidQueueMonitor::Engine => '/solid_queue' 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/db/.gitkeep: -------------------------------------------------------------------------------- 1 | # This file ensures the db directory is tracked by Git -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20240311000000_create_solid_queue_tables.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateSolidQueueTables < ActiveRecord::Migration[7.0] 4 | def change 5 | create_table :solid_queue_jobs do |t| 6 | t.string :queue_name, null: false 7 | t.string :class_name, null: false 8 | t.text :arguments 9 | t.datetime :scheduled_at 10 | t.datetime :finished_at 11 | t.timestamps 12 | end 13 | 14 | create_table :solid_queue_scheduled_executions do |t| 15 | t.references :job, null: false 16 | t.string :queue_name, null: false 17 | t.datetime :scheduled_at, null: false 18 | t.integer :priority, default: 0, null: false 19 | t.timestamps 20 | end 21 | 22 | create_table :solid_queue_ready_executions do |t| 23 | t.references :job, null: false 24 | t.string :queue_name, null: false 25 | t.integer :priority, default: 0, null: false 26 | t.timestamps 27 | end 28 | 29 | create_table :solid_queue_failed_executions do |t| 30 | t.references :job, null: false 31 | t.text :error 32 | t.timestamps 33 | end 34 | 35 | create_table :solid_queue_recurring_tasks do |t| 36 | t.string :key, null: false 37 | t.string :class_name, null: false 38 | t.string :queue_name, null: false 39 | t.string :schedule 40 | t.text :arguments 41 | t.timestamps 42 | end 43 | 44 | add_index :solid_queue_recurring_tasks, :key, unique: true 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is auto-generated from the current state of the database. Instead 4 | # of editing this file, please use the migrations feature of Active Record to 5 | # incrementally modify your database, and then regenerate this schema definition. 6 | # 7 | # This file is the source Rails uses to define your schema when running `bin/rails 8 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 9 | # be faster and is potentially less error prone than running all of your 10 | # migrations from scratch. Old migrations may fail to apply correctly if those 11 | # migrations use external dependencies or application code. 12 | # 13 | # It's strongly recommended that you check this file into your version control system. 14 | 15 | ActiveRecord::Schema[7.0].define(version: 20_230_101_000_001) do 16 | create_table 'solid_queue_blocked_executions', force: :cascade do |t| 17 | t.integer 'job_id', null: false 18 | t.string 'queue_name', null: false 19 | t.integer 'priority', default: 0, null: false 20 | t.string 'concurrency_key', null: false 21 | t.datetime 'expires_at', null: false 22 | t.datetime 'created_at', null: false 23 | t.index %w[concurrency_key priority job_id], name: 'index_solid_queue_blocked_executions_for_release' 24 | t.index %w[expires_at concurrency_key], name: 'index_solid_queue_blocked_executions_for_maintenance' 25 | t.index ['job_id'], name: 'index_solid_queue_blocked_executions_on_job_id', unique: true 26 | end 27 | 28 | create_table 'solid_queue_claimed_executions', force: :cascade do |t| 29 | t.integer 'job_id', null: false 30 | t.bigint 'process_id' 31 | t.datetime 'created_at', null: false 32 | t.index ['job_id'], name: 'index_solid_queue_claimed_executions_on_job_id', unique: true 33 | t.index %w[process_id job_id], name: 'index_solid_queue_claimed_executions_on_process_id_and_job_id' 34 | end 35 | 36 | create_table 'solid_queue_failed_executions', force: :cascade do |t| 37 | t.integer 'job_id', null: false 38 | t.text 'error' 39 | t.datetime 'created_at', null: false 40 | t.string 'queue_name' 41 | t.index ['job_id'], name: 'index_solid_queue_failed_executions_on_job_id', unique: true 42 | end 43 | 44 | create_table 'solid_queue_jobs', force: :cascade do |t| 45 | t.string 'queue_name', null: false 46 | t.string 'class_name', null: false 47 | t.text 'arguments' 48 | t.integer 'priority', default: 0, null: false 49 | t.string 'active_job_id' 50 | t.datetime 'scheduled_at' 51 | t.datetime 'finished_at' 52 | t.string 'concurrency_key' 53 | t.datetime 'created_at', null: false 54 | t.datetime 'updated_at', null: false 55 | t.index ['active_job_id'], name: 'index_solid_queue_jobs_on_active_job_id' 56 | t.index ['class_name'], name: 'index_solid_queue_jobs_on_class_name' 57 | t.index ['finished_at'], name: 'index_solid_queue_jobs_on_finished_at' 58 | t.index %w[queue_name finished_at], name: 'index_solid_queue_jobs_on_queue_name_and_finished_at' 59 | end 60 | 61 | create_table 'solid_queue_pauses', force: :cascade do |t| 62 | t.string 'queue_name', null: false 63 | t.datetime 'created_at', null: false 64 | t.index ['queue_name'], name: 'index_solid_queue_pauses_on_queue_name', unique: true 65 | end 66 | 67 | create_table 'solid_queue_processes', force: :cascade do |t| 68 | t.string 'kind', null: false 69 | t.datetime 'last_heartbeat_at', null: false 70 | t.bigint 'supervisor_id' 71 | t.integer 'pid', null: false 72 | t.string 'hostname' 73 | t.text 'metadata' 74 | t.datetime 'created_at', null: false 75 | t.index ['last_heartbeat_at'], name: 'index_solid_queue_processes_on_last_heartbeat_at' 76 | t.index ['supervisor_id'], name: 'index_solid_queue_processes_on_supervisor_id' 77 | end 78 | 79 | create_table 'solid_queue_ready_executions', force: :cascade do |t| 80 | t.integer 'job_id', null: false 81 | t.string 'queue_name', null: false 82 | t.integer 'priority', default: 0, null: false 83 | t.datetime 'created_at', null: false 84 | t.index ['job_id'], name: 'index_solid_queue_ready_executions_on_job_id', unique: true 85 | t.index %w[priority job_id], name: 'index_solid_queue_poll_all' 86 | t.index %w[queue_name priority job_id], name: 'index_solid_queue_poll_by_queue' 87 | end 88 | 89 | create_table 'solid_queue_recurring_tasks', force: :cascade do |t| 90 | t.string 'key', null: false 91 | t.string 'schedule', null: false 92 | t.string 'command' 93 | t.string 'class_name' 94 | t.text 'arguments' 95 | t.string 'queue_name' 96 | t.integer 'priority' 97 | t.boolean 'static', default: false, null: false 98 | t.text 'description' 99 | t.datetime 'created_at', null: false 100 | t.datetime 'updated_at', null: false 101 | t.index ['key'], name: 'index_solid_queue_recurring_tasks_on_key', unique: true 102 | end 103 | 104 | create_table 'solid_queue_scheduled_executions', force: :cascade do |t| 105 | t.integer 'job_id', null: false 106 | t.string 'queue_name', null: false 107 | t.integer 'priority', default: 0, null: false 108 | t.datetime 'scheduled_at', null: false 109 | t.datetime 'created_at', null: false 110 | t.index ['job_id'], name: 'index_solid_queue_scheduled_executions_on_job_id', unique: true 111 | t.index %w[scheduled_at priority job_id], name: 'index_solid_queue_dispatch_all' 112 | t.index %w[scheduled_at queue_name priority job_id], name: 'index_solid_queue_dispatch_by_queue' 113 | end 114 | 115 | create_table 'solid_queue_semaphores', force: :cascade do |t| 116 | t.string 'key', null: false 117 | t.integer 'value', default: 1, null: false 118 | t.datetime 'expires_at', null: false 119 | t.datetime 'created_at', null: false 120 | t.datetime 'updated_at', null: false 121 | t.index ['expires_at'], name: 'index_solid_queue_semaphores_on_expires_at' 122 | t.index %w[key value], name: 'index_solid_queue_semaphores_on_key_and_value' 123 | t.index ['key'], name: 'index_solid_queue_semaphores_on_key', unique: true 124 | end 125 | 126 | add_foreign_key 'solid_queue_blocked_executions', 'solid_queue_jobs', column: 'job_id', on_delete: :cascade 127 | add_foreign_key 'solid_queue_claimed_executions', 'solid_queue_jobs', column: 'job_id', on_delete: :cascade 128 | add_foreign_key 'solid_queue_failed_executions', 'solid_queue_jobs', column: 'job_id', on_delete: :cascade 129 | add_foreign_key 'solid_queue_ready_executions', 'solid_queue_jobs', column: 'job_id', on_delete: :cascade 130 | add_foreign_key 'solid_queue_scheduled_executions', 'solid_queue_jobs', column: 'job_id', on_delete: :cascade 131 | end 132 | -------------------------------------------------------------------------------- /spec/dummy/log/test.log: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vishaltps/solid_queue_monitor/2ed8c23b353e8f960657e52b5a67e31407ad70b5/spec/dummy/log/test.log -------------------------------------------------------------------------------- /spec/dummy/tmp/local_secret.txt: -------------------------------------------------------------------------------- 1 | 8277f55825ecd193db3d8f77758d304553d6e71d2e8fd035f6292ae1d27cbbcb6667d6f58795846ee6277ec802b78f215f7e46617555311a34e7aa000bbee0e3 -------------------------------------------------------------------------------- /spec/features/solid_queue_monitor/dashboard_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'Dashboard' do 6 | before do 7 | page.driver.basic_authorize('admin', 'password123') 8 | end 9 | 10 | it 'displays the dashboard' do 11 | visit '/queue' 12 | expect(page).to have_content('Solid Queue Monitor') 13 | expect(page).to have_content('Queue Status Overview') 14 | end 15 | 16 | it 'shows job statistics' do 17 | visit '/queue' 18 | expect(page).to have_content('Total Jobs') 19 | expect(page).to have_content('Scheduled') 20 | expect(page).to have_content('Failed') 21 | end 22 | 23 | context 'with scheduled jobs' do 24 | before do 25 | create_scheduled_job 26 | end 27 | 28 | it 'allows executing scheduled jobs' do 29 | visit '/queue' 30 | expect(page).to have_button('Execute Now') 31 | 32 | click_button 'Execute Now' 33 | expect(page).to have_content('Job moved to ready queue') 34 | end 35 | end 36 | 37 | private 38 | 39 | def create_scheduled_job 40 | job = SolidQueue::Job.create!( 41 | class_name: 'TestJob', 42 | queue_name: 'default' 43 | ) 44 | SolidQueue::ScheduledExecution.create!( 45 | job: job, 46 | queue_name: 'default', 47 | scheduled_at: 1.hour.from_now 48 | ) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/presenters/solid_queue_monitor/jobs_presenter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe SolidQueueMonitor::JobsPresenter do 6 | describe '#render' do 7 | subject { described_class.new(jobs, current_page: 1, total_pages: 1, filters: {}) } 8 | 9 | let(:job1) { create(:solid_queue_job, class_name: 'EmailJob') } 10 | let(:job2) { create(:solid_queue_job, :completed, class_name: 'ReportJob') } 11 | let(:jobs) { [job1, job2] } 12 | 13 | before do 14 | allow_any_instance_of(SolidQueueMonitor::StatusCalculator).to receive(:calculate).and_return('pending', 15 | 'completed') 16 | end 17 | 18 | it 'returns HTML string' do 19 | expect(subject.render).to be_a(String) 20 | end 21 | 22 | it 'includes a title for the section' do 23 | expect(subject.render).to include('

Recent Jobs

') 24 | end 25 | 26 | it 'includes the filter form' do 27 | html = subject.render 28 | 29 | expect(html).to include('filter-form-container') 30 | expect(html).to include('Job Class:') 31 | expect(html).to include('Queue:') 32 | expect(html).to include('Status:') 33 | end 34 | 35 | it 'includes a table with jobs' do 36 | html = subject.render 37 | 38 | expect(html).to include('') 39 | expect(html).to include('EmailJob') 40 | expect(html).to include('ReportJob') 41 | expect(html).to include('status-pending') 42 | expect(html).to include('status-completed') 43 | end 44 | 45 | context 'with filters' do 46 | subject do 47 | described_class.new(jobs, current_page: 1, total_pages: 1, filters: { class_name: 'Email', status: 'pending' }) 48 | end 49 | 50 | it 'pre-fills filter values' do 51 | html = subject.render 52 | 53 | expect(html).to include('value="Email"') 54 | expect(html).to include('value="pending" selected') 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/presenters/solid_queue_monitor/stats_presenter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe SolidQueueMonitor::StatsPresenter do 6 | describe '#render' do 7 | subject { described_class.new(stats) } 8 | 9 | let(:stats) do 10 | { 11 | total_jobs: 100, 12 | unique_queues: 5, 13 | scheduled: 20, 14 | ready: 30, 15 | failed: 10, 16 | completed: 40 17 | } 18 | end 19 | 20 | it 'returns HTML string' do 21 | expect(subject.render).to be_a(String) 22 | expect(subject.render).to include('', '') 23 | end 24 | 25 | it 'includes all stats in the output' do 26 | html = subject.render 27 | 28 | expect(html).to include('Queue Statistics') 29 | expect(html).to include('Total Jobs') 30 | expect(html).to include('100') 31 | expect(html).to include('Unique Queues') 32 | expect(html).to include('5') 33 | expect(html).to include('Scheduled') 34 | expect(html).to include('20') 35 | expect(html).to include('Ready') 36 | expect(html).to include('30') 37 | expect(html).to include('Failed') 38 | expect(html).to include('10') 39 | expect(html).to include('Completed') 40 | expect(html).to include('40') 41 | end 42 | 43 | it 'does not include recurring jobs count' do 44 | html = subject.render 45 | 46 | expect(html).not_to include('Recurring') 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/services/solid_queue_monitor/authentication_service_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe SolidQueueMonitor::AuthenticationService do 6 | describe '.authenticate' do 7 | context 'when authentication is disabled' do 8 | before do 9 | allow(SolidQueueMonitor).to receive(:authentication_enabled).and_return(false) 10 | end 11 | 12 | it 'returns true regardless of credentials' do 13 | expect(described_class.authenticate('wrong', 'wrong')).to be true 14 | expect(described_class.authenticate(nil, nil)).to be true 15 | end 16 | end 17 | 18 | context 'when authentication is enabled' do 19 | before do 20 | allow(SolidQueueMonitor).to receive_messages(authentication_enabled: true, username: 'admin', 21 | password: 'password') 22 | end 23 | 24 | it 'returns true when credentials are correct' do 25 | expect(described_class.authenticate('admin', 'password')).to be true 26 | end 27 | 28 | it 'returns false when username is incorrect' do 29 | expect(described_class.authenticate('wrong', 'password')).to be false 30 | end 31 | 32 | it 'returns false when password is incorrect' do 33 | expect(described_class.authenticate('admin', 'wrong')).to be false 34 | end 35 | 36 | it 'returns false when both username and password are incorrect' do 37 | expect(described_class.authenticate('wrong', 'wrong')).to be false 38 | end 39 | end 40 | end 41 | 42 | describe '.authentication_required?' do 43 | it 'returns the value of SolidQueueMonitor.authentication_enabled' do 44 | allow(SolidQueueMonitor).to receive(:authentication_enabled).and_return(true) 45 | expect(described_class.authentication_required?).to be true 46 | 47 | allow(SolidQueueMonitor).to receive(:authentication_enabled).and_return(false) 48 | expect(described_class.authentication_required?).to be false 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/services/solid_queue_monitor/execute_job_service_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe SolidQueueMonitor::ExecuteJobService do 6 | describe '#execute_many' do 7 | subject { described_class.new } 8 | 9 | let!(:scheduled_execution1) { create(:solid_queue_scheduled_execution) } 10 | let!(:scheduled_execution2) { create(:solid_queue_scheduled_execution) } 11 | 12 | it 'moves scheduled jobs to ready queue' do 13 | expect do 14 | subject.execute_many([scheduled_execution1.id, scheduled_execution2.id]) 15 | end.to change(SolidQueue::ReadyExecution, :count).by(2) 16 | .and change(SolidQueue::ScheduledExecution, :count).by(-2) 17 | end 18 | 19 | it 'preserves job attributes when moving to ready queue' do 20 | subject.execute_many([scheduled_execution1.id]) 21 | 22 | ready_execution = SolidQueue::ReadyExecution.last 23 | expect(ready_execution.job_id).to eq(scheduled_execution1.job_id) 24 | expect(ready_execution.queue_name).to eq(scheduled_execution1.queue_name) 25 | expect(ready_execution.priority).to eq(scheduled_execution1.priority) 26 | end 27 | 28 | it 'handles non-existent job IDs gracefully' do 29 | expect do 30 | subject.execute_many([999_999]) 31 | end.not_to change(SolidQueue::ReadyExecution, :count) 32 | end 33 | 34 | it 'handles empty job IDs array gracefully' do 35 | expect do 36 | subject.execute_many([]) 37 | end.not_to change(SolidQueue::ReadyExecution, :count) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/services/solid_queue_monitor/pagination_service_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe SolidQueueMonitor::PaginationService do 6 | describe '#paginate' do 7 | let!(:jobs) { create_list(:solid_queue_job, 30) } 8 | let(:relation) { SolidQueue::Job.all } 9 | 10 | context 'with default page size' do 11 | subject { described_class.new(relation, 1, 25) } 12 | 13 | it 'returns a hash with records, current_page, and total_pages' do 14 | result = subject.paginate 15 | 16 | expect(result).to be_a(Hash) 17 | expect(result).to include(:records, :current_page, :total_pages) 18 | end 19 | 20 | it 'limits records to the page size' do 21 | result = subject.paginate 22 | 23 | expect(result[:records].size).to eq(25) 24 | end 25 | 26 | it 'calculates total pages correctly' do 27 | result = subject.paginate 28 | 29 | expect(result[:total_pages]).to eq(2) 30 | end 31 | end 32 | 33 | context 'with custom page size' do 34 | subject { described_class.new(relation, 1, 10) } 35 | 36 | it 'limits records to the specified page size' do 37 | result = subject.paginate 38 | 39 | expect(result[:records].size).to eq(10) 40 | expect(result[:total_pages]).to eq(3) 41 | end 42 | end 43 | 44 | context 'with page navigation' do 45 | subject { described_class.new(relation, 2, 10) } 46 | 47 | it 'returns the correct page of records' do 48 | result = subject.paginate 49 | 50 | expect(result[:records].size).to eq(10) 51 | expect(result[:current_page]).to eq(2) 52 | 53 | # The records should be different from page 1 54 | page1 = described_class.new(relation, 1, 10).paginate[:records] 55 | expect(result[:records]).not_to eq(page1) 56 | end 57 | end 58 | 59 | context 'with empty relation' do 60 | subject { described_class.new(SolidQueue::Job.where(id: -1), 1, 25) } 61 | 62 | it 'returns empty records with correct pagination info' do 63 | result = subject.paginate 64 | 65 | expect(result[:records]).to be_empty 66 | expect(result[:current_page]).to eq(1) 67 | expect(result[:total_pages]).to eq(0) 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/services/solid_queue_monitor/stats_calculator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe SolidQueueMonitor::StatsCalculator do 6 | describe '.calculate' do 7 | before do 8 | # Create some test data 9 | create_list(:solid_queue_job, 3) 10 | create(:solid_queue_job, :completed) 11 | create(:solid_queue_job, :completed) 12 | create(:solid_queue_job, queue_name: 'high_priority') 13 | create(:solid_queue_failed_execution) 14 | create(:solid_queue_scheduled_execution) 15 | create(:solid_queue_ready_execution) 16 | end 17 | 18 | it 'returns a hash with all required statistics' do 19 | stats = described_class.calculate 20 | 21 | expect(stats).to be_a(Hash) 22 | expect(stats).to include( 23 | :total_jobs, 24 | :unique_queues, 25 | :scheduled, 26 | :ready, 27 | :failed, 28 | :completed 29 | ) 30 | end 31 | 32 | it 'calculates the correct counts' do 33 | stats = described_class.calculate 34 | 35 | expect(stats[:total_jobs]).to eq(6) 36 | expect(stats[:unique_queues]).to eq(2) 37 | expect(stats[:scheduled]).to eq(1) 38 | expect(stats[:ready]).to eq(1) 39 | expect(stats[:failed]).to eq(1) 40 | expect(stats[:completed]).to eq(2) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/services/solid_queue_monitor/status_calculator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe SolidQueueMonitor::StatusCalculator do 6 | describe '#calculate' do 7 | let(:job) { create(:solid_queue_job) } 8 | 9 | context 'when job is completed' do 10 | let(:completed_job) { create(:solid_queue_job, :completed) } 11 | 12 | it 'returns completed status' do 13 | calculator = described_class.new(completed_job) 14 | expect(calculator.calculate).to eq('completed') 15 | end 16 | end 17 | 18 | context 'when job has failed' do 19 | before do 20 | create(:solid_queue_failed_execution, job: job) 21 | job.define_singleton_method(:failed?) { true } 22 | end 23 | 24 | it 'returns failed status' do 25 | calculator = described_class.new(job) 26 | expect(calculator.calculate).to eq('failed') 27 | end 28 | end 29 | 30 | context 'when job is scheduled for the future' do 31 | before do 32 | job.scheduled_at = 1.hour.from_now 33 | end 34 | 35 | it 'returns scheduled status' do 36 | calculator = described_class.new(job) 37 | expect(calculator.calculate).to eq('scheduled') 38 | end 39 | end 40 | 41 | context 'when job is pending' do 42 | it 'returns pending status' do 43 | calculator = described_class.new(job) 44 | expect(calculator.calculate).to eq('pending') 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV['RAILS_ENV'] ||= 'test' 4 | 5 | require 'rails' 6 | require 'solid_queue' 7 | require 'solid_queue_monitor' 8 | 9 | # Load the Rails application 10 | ENV['RAILS_ENV'] = 'test' 11 | require File.expand_path('dummy/config/environment', __dir__) 12 | 13 | # Prevent database truncation if the environment is production 14 | abort('The Rails environment is running in production mode!') if Rails.env.production? 15 | 16 | require 'rspec/rails' 17 | 18 | RSpec.configure do |config| 19 | config.infer_spec_type_from_file_location! 20 | 21 | # Configure RSpec to find spec files in the correct location 22 | config.pattern = 'spec/**/*_spec.rb' 23 | end 24 | --------------------------------------------------------------------------------