├── .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 | [](https://badge.fury.io/rb/solid_queue_monitor)
4 | [](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 | 
37 |
38 | ### Failed Jobs
39 |
40 | 
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 |
47 |
48 |
49 | Retry Selected
50 | Discard Selected
51 |
52 | HTML
53 | end
54 |
55 | def generate_table
56 | <<-HTML
57 |
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 |
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 |
41 | HTML
42 | end
43 |
44 | def generate_table
45 | <<-HTML
46 |
47 |
48 |
49 |
50 | Job
51 | Queue
52 | Arguments
53 | Started At
54 | Process ID
55 |
56 |
57 |
58 | #{@jobs.map { |execution| generate_row(execution) }.join}
59 |
60 |
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 |
66 | HTML
67 | end
68 |
69 | def generate_table
70 | <<-HTML
71 |
72 |
73 |
74 |
75 | ID
76 | Job
77 | Queue
78 | Arguments
79 | Status
80 | Created At
81 | Actions
82 |
83 |
84 |
85 | #{@jobs.map { |job| generate_row(job) }.join}
86 |
87 |
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 |
119 |
120 |
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 | Queue Name
22 | Total Jobs
23 | Ready Jobs
24 | Scheduled Jobs
25 | Failed Jobs
26 |
27 |
28 |
29 | #{@records.map { |queue| generate_row(queue) }.join}
30 |
31 |
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 |
44 | HTML
45 | end
46 |
47 | def generate_table
48 | <<-HTML
49 |
50 |
51 |
52 |
53 | Job
54 | Queue
55 | Priority
56 | Arguments
57 | Created At
58 |
59 |
60 |
61 | #{@jobs.map { |execution| generate_row(execution) }.join}
62 |
63 |
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 |
42 | HTML
43 | end
44 |
45 | def generate_table
46 | <<-HTML
47 |
48 |
49 |
50 |
51 | Key
52 | Job
53 | Schedule
54 | Queue
55 | Priority
56 | Last Updated
57 |
58 |
59 |
60 | #{@jobs.map { |task| generate_row(task) }.join}
61 |
62 |
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 |
46 |
47 |
48 | Execute Selected
49 |
50 | HTML
51 | end
52 |
53 | def generate_table_with_actions
54 | <<-HTML
55 |
58 |
104 | HTML
105 | end
106 |
107 | def generate_table
108 | <<-HTML
109 |
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 |
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 |
--------------------------------------------------------------------------------