Skip to content

Repository files navigation

Solid Queue Panel

A web panel for Solid Queue, in the spirit of the Sidekiq web UI: one place to see what your workers are doing, which queues are backing up, why a job failed, and how the whole thing is configured.

CI MIT License

Solid Queue Panel dashboard

Add the gem, run the installer, sign in. No migrations, no assets to precompile, no JavaScript build, no external requests — and a sign in page so it is never accidentally public.

Getting started

# Gemfile
gem "solid_queue_panel"
bundle install
bin/rails generate solid_queue_panel:install

The installer mounts the panel at /jobs, writes config/initializers/solid_queue_panel.rb, and generates the credentials you will sign in with:

      create  config/initializers/solid_queue_panel.rb
 credentials  stored solid_queue_panel.password
       route  mount SolidQueuePanel::Engine => "/jobs"

Solid Queue Panel is mounted at /jobs

  Username: admin
  Password: 2xK9pQmR7vTnB4wYhZ3sLdFa

The password is stored in your encrypted credentials. Write it down: this is the
only time it is printed.

Start your app, open /jobs, and sign in with those credentials.

bin/rails generate solid_queue_panel:install --at /admin/jobs --username YOURNAME

Sign in page

Signing in

The panel signs your browser in for two weeks with a signed, http-only cookie, so you type the password once and your password manager keeps it. Sign out from the button in the navigation bar, or change the duration:

config.session_duration = 90.days

The same credentials are accepted as HTTP basic authentication, which is handy for curl or an uptime check:

curl -u admin:2xK9pQmR7vTnB4wYhZ3sLdFa https://example.com/jobs

Where the password lives

The installer stores it in your encrypted credentials, under solid_queue_panel.password, and the generated initializer reads it from there — or from the environment, which wins when both are set:

config.username = ENV.fetch("SOLID_QUEUE_PANEL_USERNAME", "admin")
config.password = ENV.fetch("SOLID_QUEUE_PANEL_PASSWORD") { Rails.application.credentials.dig(:solid_queue_panel, :password) }

If your application has no master key, set SOLID_QUEUE_PANEL_PASSWORD in the environment of every process that serves the panel instead.

Changing the password

bin/rails solid_queue_panel:password

It prints a fresh random password and where to put it. Changing the password signs every browser out.

bin/rails credentials:edit   # to store the new one

Other ways to protect it

Setting a username and a password turns the sign in form on. If your application already knows who its administrators are, skip it and mount the panel behind your own authentication instead:

# config/routes.rb
authenticate :user, ->(user) { user.admin? } do  # Devise
  mount SolidQueuePanel::Engine => "/jobs"
end
# config/initializers/solid_queue_panel.rb — the block runs in the controller
config.authenticate_with { redirect_to(main_app.root_path) unless current_user&.admin? }

And whatever you pick, a panel that only needs to be watched can be made harmless:

config.read_only = true   # hides and refuses retry, discard, pause, resume, clear, prune

Warning

Leaving username and password blank, with no authenticate_with block and no route constraint, leaves the panel open to anyone who can reach the URL. It can pause queues and discard jobs.

What you get

Page What it shows
Dashboard The capacity table: for every queue, the threads that can work on it, what they are running right now, what is pending, when it was last added to, and when it will be empty — plus retries, scheduled and dead jobs. Along with every job being processed right now, each with a progress bar of how long it has been running against how long jobs like it take, and alerts when something is silently wrong: no worker running, dead processes, paused queues, a different Active Job adapter.
Processes Supervisors with their workers, dispatchers and schedulers: queues polled, thread pool size and how much of it is busy, polling interval, heartbeat, and every job currently running. Dead processes can be pruned from here.
Queues Per queue counters and a backlog bar broken down by state, plus latency — how long the oldest job has been waiting. Queues can be paused, resumed and cleared.
Queue detail The jobs of a single queue, filtered by state: the fastest way to answer "what is stuck in this queue?".
Jobs Every job, filtered by state, queue or search (job class, job id or Active Job id), with retry, run now, discard, bulk actions, putting a whole list back to work and duplicate removal.
Job detail The Active Job payload, timings, attempts, concurrency key, the worker running it, and the full error with backtrace when it failed.
Recurring Recurring tasks with their schedule, target, queue, last run and next run, plus the latest runs of each task.
Metrics A throughput chart of jobs enqueued, finished and failed, with arrival and completion rates, and a table per job class: enqueued, finished, failed, failure rate, jobs in progress, average and total time.
Resources How much memory and CPU every machine and every Solid Queue process is using, over time, what each job class costs, and concrete advice on how many processes and threads this machine can take.
Settings The whole Solid Queue configuration with an explanation of every setting, the processes Solid Queue would start, the contents of config/queue.yml and config/recurring.yml, the database the queue lives in, and the versions in use — plus a Copy button that puts all of it, as text, on your clipboard.

Every page refreshes itself while you watch it, in light or dark theme, and works down to a phone screen.

Screenshots

Queues
Queues, with backlog bars and latency
Processes
Processes, threads and jobs in progress
Jobs
Jobs, filtered by state, with bulk actions
Duplicates
Counting the exact copies before discarding them
Resources
Memory, CPU and tuning advice per machine
Settings
Settings: the whole Solid Queue configuration
Metrics
Throughput and metrics per job class
Dark theme
Dark theme, following the system by default

Capacity and ETA

The table on the dashboard answers the question you actually have when a queue starts growing: is there anyone working on it, and when will it be done?

  • Last 24h is the shape of the day: jobs finished as a filled area, jobs arriving as a dashed line over it, hour by hour. A line pulling away from the area is a queue being fed faster than it is drained, long before the pending count makes it obvious.
  • Capacity is the number of worker threads polling the queue, wildcards resolved the way Solid Queue resolves them. A worker polling several queues lends its threads to all of them, so capacities overlap: the number says how many jobs of that queue could be running right now.
  • In progress is what those threads are doing, green until 70% of the capacity, amber up to 95%, red when every thread is taken.
  • Pending is what is waiting in the queue, and Last added is how long ago the queue was last added to.
  • ETA spreads the work waiting over the threads that can pick it up, using what jobs like those usually take. When some of the jobs waiting have never been seen before the estimate is shown as a floor ("at least 4m"), and when none of them has, it says unknown rather than making something up.
  • The remaining columns are every state Solid Queue can put a job in: Blocked by a concurrency limit, Scheduled for later, Retries for the ones that already raised and will run again, Dead for the ones that failed and will not be retried until you say so, and Finished 24h for the ones that made it, for as long as Solid Queue keeps them. Each number is a link to that list.

How long a job takes

Estimates — the ETA of a queue, the progress bar of a running job — are only as good as what they are based on, so the panel looks for the most specific answer it has and says which one it used:

  1. The same job with the same arguments. SyncJob.perform_later(10) and SyncJob.perform_later(2_000) are the same class and rarely the same amount of work, so runs are remembered per set of arguments. This needs the resource metrics, which record the execution time measured inside the workers.
  2. The same job class, when those particular arguments have never been seen.
  3. Nothing. No history, no estimate: the panel shows unknown and no progress bar, instead of a number that would be a guess.

Hovering a progress bar tells you when the job is expected to finish, how many runs the estimate is based on, and whether those runs were measured inside the worker or inferred from the time between enqueue and completion. A bar that fills past 100% turns red: the job is taking longer than its history says it should.

Putting a list back to work

Every list of jobs that are not running has a button that puts all of them back to work, filters included, so a queue that failed during an outage does not have to be clicked back one job at a time:

  • Retry all on the failed list enqueues them again, with their attempt counters reset, exactly as retrying each of them by hand would.
  • Run all now on the scheduled list moves their due date to now, and the dispatcher picks them up on its next poll — through the usual concurrency checks, not around them.
  • Release all on the blocked list offers each of them their concurrency lock again. The ones that can take it start running, the ones that cannot stay blocked: the button asks Solid Queue to try, it does not bypass the limit.

Each of them works through at most 5,000 jobs per press, oldest first, and says so when there are more.

Removing duplicates

The queued tab has a Duplicates button. It opens a page that counts the copies first and lists what they are copies of, so discarding them is a decision rather than a surprise — and so the count, which walks the queue, only runs when you ask for it rather than on every page you look at. Confirming discards every copy but the first of each group.

Exact is meant literally. Two jobs are copies only when all of this matches:

  • the job class, the queue and the priority,
  • the concurrency key,
  • the entire Active Job payload: arguments, locale, timezone, number of attempts, everything.

The only fields ignored are the ones Active Job generates for every single job and that therefore can never be equal: job_id, enqueued_at and provider_job_id.

Only jobs still waiting in their queue are considered. A job a worker has already claimed, or a scheduled, blocked or failed one, is never touched — and neither is the first copy of each group. Discarding goes through Solid Queue, so any concurrency lock the discarded jobs held is released.

The scan compares payloads in Ruby, so it stops at the 100,000 oldest queued jobs and tells you how many it looked at. Run it again to work through a longer queue.

Copying the whole setup

The Copy button on the settings page puts a plain text report of this installation on your clipboard: versions, every Solid Queue setting, the processes configured and the processes actually running, the state of every queue, what each machine is using, the heaviest job classes, and the configuration files as they are written. It is meant to be pasted somewhere that is not the panel — into an LLM helping you tune the queue, into a bug report, into a message to whoever is on call:

## Queues

background  capacity=15  in_progress=2  pending=14  blocked=6  scheduled=2  retries=2  dead=1
            finished_24h=72  last_added=63s ago  eta=87s

## Machine jobs-01

cores=4  memory=8192MB  used=64%  load=1.99  load_per_core=50%
solid_queue_memory=945MB  per_worker=234MB  workers=3  threads_per_worker=5
advice:
  - Each worker holds 234 MB, and the machine is using 64% of its 8,192 MB. There is room for about
    10 worker processes of that size.

It never includes credentials, and it is built when you press the button rather than every time the page refreshes.

Sensitive payloads and the audit trail

The panel reads the tables Solid Queue already has, so the only data it shows that belongs to your application rather than to the queue is what you passed to perform_later. Two settings decide how much of it anybody watching the queue gets to see, and everything the panel can change is written down.

What is filtered

Payloads are filtered by key, exactly the way Rails already filters its own logs, reusing your application's config.filter_parameters:

# Nothing to do: a key worth hiding from the log is hidden here too.
# WelcomeJob.perform_later(42, email: "someone@example.com", plan: "pro")
# shows as 42, {"email": "[FILTERED]", "plan": "pro"}

Override it with a list of your own, or empty it to show payloads exactly as they are stored:

config.filter_parameters = [ :email, :ssn, /patient/ ]

Filtering is by key, which is its limit: an argument passed as a bare value has nothing naming it, so WelcomeJob.perform_later("someone@example.com") cannot be filtered. When the payloads next to your queue are ones nobody should read, show none of them:

config.hide_job_arguments = true   # the payload reads [HIDDEN], lists show no arguments

Note

Filtering covers payloads, not the message and backtrace of a failed job: exceptions routinely quote the value that caused them, and no filter can find it in free text. A panel that must not show application data at all belongs behind read_only and your own authentication, with hide_job_arguments set.

What is written down

Every action that changes something — retry, discard, bulk actions, run all, remove duplicates, pause, resume, clear, prune — is written to the audit logger, which is your application logger unless you point it elsewhere:

[SolidQueuePanel] action=clear_queue actor=alice@example.com ip=10.0.0.4 queue_name=default

Each entry is also published as an ActiveSupport::Notifications event, for audit trails that do not live in a log file:

ActiveSupport::Notifications.subscribe(SolidQueuePanel::Audit::EVENT) do |*, entry|
  AuditEntry.create!(entry)
end

The actor is whatever config.audit_actor returns:

config.audit_actor { current_user&.email }

Without it the panel records how the request was authenticated — shared credentials, application authentication, unauthenticated — rather than naming somebody it cannot identify.

One password identifies nobody

The built-in sign in form is one username and one password for everybody, which is enough to keep a panel off the public internet and not enough to say who did what. Where that matters — where an auditor will ask, or where the rules require each person to be told apart — mount the panel behind the authentication your application already has, and name the actor:

# config/initializers/solid_queue_panel.rb
config.username = nil
config.password = nil
config.authenticate_with { redirect_to(main_app.root_path) unless current_user&.admin? }
config.audit_actor { current_user.email }

That, hide_job_arguments, and read_only where the panel only needs watching, are the three settings behind any claim about who can see or change what. The panel makes no outbound requests and sends nothing anywhere, so what it shows is bounded by who can reach the URL.

What the panel stores

Only the optional resource metrics tables, and only measurements: how long a job class ran, how much CPU and memory it used. Arguments are never stored. Runs are told apart by a truncated SHA-256 of the serialized arguments, which is a pseudonym rather than an anonymous value — a small, guessable set of arguments can be recognised by hashing candidates — and rows are pruned after config.resource_retention, three days by default.

Resource metrics

The panel can measure how much memory and CPU Solid Queue is actually using, on every machine, and what each job class costs. It is not installed by default because it needs two tables of its own:

bin/rails generate solid_queue_panel:resource_metrics
bin/rails db:migrate

Restart your Solid Queue processes and the Resources page fills up. The migration goes to the migrations path of the database Solid Queue uses, so a dedicated queue database is handled on its own.

What gets recorded, from inside the Solid Queue processes themselves:

  • Per process, every 30 seconds: resident memory and CPU, plus the memory, cores and load average of the machine. In a container the cgroup limits are read instead of the size of the host, which is what you want on Kubernetes, Docker or a Heroku-style dyno.
  • Per job class, and per set of arguments, accumulated in memory and written on the same tick: how many jobs ran, how long they took, how much CPU they used (measured on the thread that ran the job) and how much the process grew while they ran. Jobs called with a different argument every time would fill the table with rows nobody will ever read, so only the most frequent sets of each tick are kept — at most 25 per class — while the total of the class is always written.

That is one row per process per sample, and a handful of rows an hour for the job usage — no matter how many jobs run. Samples are pruned after three days.

The page turns it into the two decisions you have to make:

Each worker holds 233 MB, and the machine is using 64% of its 8,192 MB. There is room for about 10 worker processes of that size.

Load average is 2.26 on 4 cores, 56% per core. The machine is comfortably busy.

Running 3 processes × 5 threads. A starting point to measure against: 4 × 5.

...along with the config/queue.yml snippet for that suggestion, and a table of the job classes that are burning the most CPU and growing the process the most.

Memory attribution has one honest caveat: resident memory belongs to the whole process, so when a worker runs several threads the growth attributed to one job is an indication rather than an invoice. CPU time is per thread, and exact.

Requirements

  • Ruby 3.2 or newer
  • Rails 7.2 or newer
  • Solid Queue 1.0 or newer

Installing by hand

If you would rather not run the generator:

# config/routes.rb
mount SolidQueuePanel::Engine => "/jobs"
# config/initializers/solid_queue_panel.rb
SolidQueuePanel.configure do |config|
  config.username = ENV["SOLID_QUEUE_PANEL_USERNAME"]
  config.password = ENV["SOLID_QUEUE_PANEL_PASSWORD"]
end

That is the whole installation. The panel reads the tables Solid Queue already has, and serves its own CSS and JavaScript from the gem, so there is nothing to migrate and nothing to add to your asset pipeline (it works the same with Propshaft, Sprockets or neither).

Configuration

Everything is optional except the credentials. These are the defaults:

SolidQueuePanel.configure do |config|
  # Sign in credentials. Both must be set for the sign in form to be enabled.
  config.username = nil
  config.password = nil

  # How long a browser stays signed in.
  config.session_duration = 2.weeks

  # Name shown in the navigation bar, next to the logo.
  config.application_name = nil

  # Rows per page in every table.
  config.page_size = 25

  # Seconds between two live refreshes. Set to 0 to disable polling entirely.
  config.polling_interval = 5

  # Hide and refuse every destructive action.
  config.read_only = false

  # Keys whose values are replaced with [FILTERED] in every payload shown.
  # Nil means the application's own config.filter_parameters.
  config.filter_parameters = nil

  # Show no job arguments at all, anywhere.
  config.hide_job_arguments = false

  # Where the audit trail of every action that changes something is written.
  # Nil means the application logger.
  config.audit_logger = nil

  # Names whoever is behind a request in the audit trail.
  # config.audit_actor { current_user&.email }

  # Periods (in hours) offered by the dashboard and metrics time filters.
  config.time_periods = [ 1, 6, 24, 24 * 7 ]

  # Record memory and CPU from inside the Solid Queue processes. Nothing is
  # recorded until the resource metrics tables are installed, whatever this is.
  config.record_resource_metrics = true

  # Seconds between two resource samples, per process.
  config.resource_sample_interval = 30.seconds

  # How long samples and per job class usage are kept.
  config.resource_retention = 3.days

  # Controller the panel controllers inherit from. Use it to reuse the
  # authentication, layout or callbacks of your own admin section.
  config.base_controller_class = "ActionController::Base"

  # Authenticate with the host application instead of the sign in form.
  # config.authenticate_with { redirect_to(main_app.root_path) unless current_user&.admin? }
end

Visitors can switch between the light, dark and system themes, and pause the live refresh, from the navigation bar. Both choices are remembered in their browser.

Notes for large installations

The panel is built to stay cheap on databases with millions of jobs:

  • Counters that could scan a large table are bounded. "Finished 24h" counts the last day only, using the index on finished_at.
  • Queue counters are collected with a handful of grouped queries, whatever the number of queues.
  • The throughput chart buckets timestamps in SQL (PostgreSQL, MySQL/MariaDB and SQLite), so it never loads a day worth of rows into memory.
  • Metrics average durations over the most recent 5,000 finished jobs of the period, and say so in the page when the sample is capped.
  • Live refresh is a single request per interval, it pauses while you are typing or selecting rows, and it stops when the tab is in the background. Raise polling_interval or set it to 0 if you want less traffic.
  • The resource recorder writes one row per process every 30 seconds and a handful of rows an hour for the job usage, whatever the throughput, and prunes itself after three days.

Two things to know about the data itself, both coming from Solid Queue rather than from this gem:

  • Finished jobs are only visible while they are kept. If SolidQueue.preserve_finished_jobs is disabled, the finished tab, the chart and the metrics have nothing to show, and the panel says so instead of pretending everything is idle.
  • Solid Queue does not record when a job started running, so durations are measured from enqueue to completion and include the time spent waiting in the queue.

Multi-database setups

Nothing to do. The panel talks to Solid Queue's own models, so it follows whatever SolidQueue.connects_to (or config.solid_queue.connects_to) points at, including a dedicated queue database.

Coming from Sidekiq

Sidekiq Here
Busy Processes, with the jobs in progress at the bottom
Queues Queues
Retries Jobs → Scheduled: Solid Queue re-schedules a job for its next attempt
Scheduled Jobs → Scheduled
Dead Jobs → Failed: Solid Queue keeps failed jobs until you retry or discard them
Metrics Metrics, and Resources for what the workers cost
Cron jobs (Sidekiq Enterprise) Recurring
— Settings, which has no Sidekiq equivalent

Solid Queue also has a state Sidekiq does not: blocked jobs, waiting on a concurrency limit, get their own tab and counter.

Development

git clone https://github.com/leoplct/solid-queue-panel.git
cd solid-queue-panel
bundle install

bin/dev            # dummy app with a seeded queue, on http://localhost:3010
bundle exec rake test
bundle exec rubocop

bin/dev rebuilds the CSS, recreates the dummy application's database, fills it with a realistic mix of jobs, processes and recurring tasks, and starts the server.

The stylesheet is Tailwind CSS v4, built from app/assets/stylesheets/solid_queue_panel/application.css into app/assets/builds/solid_queue_panel.css, which is committed so the gem needs no build step when installed. After changing any view or helper class:

bundle exec rake tailwind:build   # or rake tailwind:watch while working

Contributing

Bug reports and pull requests are welcome on GitHub. Please run bundle exec rake test and bundle exec rubocop before opening one, and rebuild the CSS bundle if you touched the markup.

Credits

  • Solid Queue, by 37signals, which does all the actual work.
  • The Sidekiq web UI, for setting the standard this panel aims at, and the RabbitMQ management UI, for the idea of charting arrivals next to completions.
  • Heroicons, MIT licensed, inlined into the gem.

License

Released under the MIT License.

About

A web dashboard for Solid Queue, in the spirit of the Sidekiq web UI: processes, queues, jobs, recurring tasks, metrics and resource usage.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages