From 649d0038b6735e0e26dfcf24bc00b9daa0e6405e Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Mon, 18 May 2026 13:05:25 -0400 Subject: [PATCH] Add retry and discard actions for failed jobs - POST /failed_jobs/:id/retry calls FailedExecution#retry (re-enqueues the job) - DELETE /failed_jobs/:id calls Execution#discard (destroys job + execution) - POST /failed_jobs/retry_all and discard_all for bulk operations - Page header with Retry All / Discard All buttons; per-row Retry / Discard buttons - Request specs covering all four actions plus the index - Disable CSRF protection in test env via rails_helper - README roadmap section; CHANGELOG updated Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 6 ++ README.md | 31 ++++++- .../solid_queue_web/application.css | 15 +++ .../solid_queue_web/failed_jobs_controller.rb | 29 ++++++ .../failed_jobs/index.html.erb | 20 +++- config/routes.rb | 10 +- spec/rails_helper.rb | 2 + .../solid_queue_web/failed_jobs_spec.rb | 93 +++++++++++++++++++ 8 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 spec/requests/solid_queue_web/failed_jobs_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index a572e32..0930c46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Retry and discard actions on individual failed jobs +- Bulk "Retry All" and "Discard All" actions for failed jobs +- Roadmap section added to README + ### Fixed - Failed jobs view now renders error class and message correctly (seed data format and missing CSS class) diff --git a/README.md b/README.md index cd0d450..ae969cb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Gem Version](https://badge.fury.io/rb/solid_queue_web.svg)](https://rubygems.org/gems/solid_queue_web) -A read-only Rails engine that mounts a monitoring dashboard for [Solid Queue](https://github.com/rails/solid_queue). View queues, inspect jobs by status, and browse failed executions — all without leaving your app. +A Rails engine that mounts a monitoring dashboard for [Solid Queue](https://github.com/rails/solid_queue). View queues, inspect jobs by status, browse failed executions, and take action — all without leaving your app. ## Features @@ -56,6 +56,35 @@ HTTP Basic authentication is used as a fallback when the block returns falsy. - Rails >= 8.1.3 - solid_queue >= 1.0 +## Roadmap + +The following features are planned. Contributions welcome. + +### Actions on failed jobs +- [ ] Retry a single failed job +- [ ] Discard (delete) a single failed job +- [ ] Bulk retry all failed jobs +- [ ] Bulk discard all failed jobs + +### Actions on ready / scheduled jobs +- [ ] Discard (cancel) a single ready or scheduled job +- [ ] Bulk discard all jobs in a queue + +### Job detail page +- [ ] Full job details: arguments, priority, attempts, scheduled time +- [ ] Full error backtrace for failed jobs + +### Pagination +- [ ] Paginate jobs, failed jobs, and queues lists + +### Queue management +- [ ] Pause / resume a queue (block new executions) + +### Process visibility +- [ ] Processes page showing workers, dispatchers, and last heartbeat + +--- + ## Contributing Bug reports and pull requests are welcome on [GitHub](https://github.com/eclectic-coding/solid_queue_web). diff --git a/app/assets/stylesheets/solid_queue_web/application.css b/app/assets/stylesheets/solid_queue_web/application.css index 6dae9ab..7480016 100644 --- a/app/assets/stylesheets/solid_queue_web/application.css +++ b/app/assets/stylesheets/solid_queue_web/application.css @@ -76,9 +76,21 @@ body { .sqd-page-title { font-size: 20px; font-weight: 600; + margin-bottom: 0; +} + +.sqd-page-header { + display: flex; + align-items: center; + justify-content: space-between; margin-bottom: 1.5rem; } +.sqd-actions { + display: flex; + gap: 0.5rem; +} + /* Flash notices */ .sqd-flash { padding: 0.75rem 1rem; @@ -218,6 +230,9 @@ tbody tr:hover { background: var(--bg); } .sqd-btn--primary { background: var(--primary); color: #fff; border-color: var(--primary); } .sqd-btn--danger { background: var(--danger); color: #fff; border-color: var(--danger); } .sqd-btn--muted { background: var(--surface); color: var(--text); border-color: var(--border); } +.sqd-btn--sm { padding: 0.2rem 0.55rem; font-size: 11px; } + +.sqd-row-actions { white-space: nowrap; text-align: right; width: 1%; } /* Filters */ .sqd-filters { diff --git a/app/controllers/solid_queue_web/failed_jobs_controller.rb b/app/controllers/solid_queue_web/failed_jobs_controller.rb index e95cc42..05242b4 100644 --- a/app/controllers/solid_queue_web/failed_jobs_controller.rb +++ b/app/controllers/solid_queue_web/failed_jobs_controller.rb @@ -6,5 +6,34 @@ def index .order(created_at: :desc) .limit(100) end + + def retry + execution = SolidQueue::FailedExecution.find(params[:id]) + execution.retry + redirect_to failed_jobs_path, notice: "Job queued for retry." + rescue => e + redirect_to failed_jobs_path, alert: "Could not retry job: #{e.message}" + end + + def destroy + execution = SolidQueue::FailedExecution.find(params[:id]) + execution.discard + redirect_to failed_jobs_path, notice: "Job discarded." + rescue => e + redirect_to failed_jobs_path, alert: "Could not discard job: #{e.message}" + end + + def retry_all + executions = SolidQueue::FailedExecution.includes(:job).to_a + jobs = executions.map(&:job) + SolidQueue::FailedExecution.retry_all(jobs) + redirect_to failed_jobs_path, notice: "#{jobs.size} #{"job".pluralize(jobs.size)} queued for retry." + end + + def discard_all + count = SolidQueue::FailedExecution.count + SolidQueue::FailedExecution.discard_all_in_batches + redirect_to failed_jobs_path, notice: "#{count} #{"job".pluralize(count)} discarded." + end end end diff --git a/app/views/solid_queue_web/failed_jobs/index.html.erb b/app/views/solid_queue_web/failed_jobs/index.html.erb index c243fae..8b28000 100644 --- a/app/views/solid_queue_web/failed_jobs/index.html.erb +++ b/app/views/solid_queue_web/failed_jobs/index.html.erb @@ -1,4 +1,14 @@ -

Failed Jobs

+
+

Failed Jobs

+ <% if @failed_jobs.any? %> +
+ <%= button_to "Retry All", retry_all_failed_jobs_path, method: :post, class: "sqd-btn sqd-btn--primary", + data: { confirm: "Retry all #{@failed_jobs.size} failed jobs?" } %> + <%= button_to "Discard All", discard_all_failed_jobs_path, method: :post, class: "sqd-btn sqd-btn--danger", + data: { confirm: "Discard all #{@failed_jobs.size} failed jobs? This cannot be undone." } %> +
+ <% end %> +
<% if @failed_jobs.empty? %> @@ -11,6 +21,7 @@ Queue Error Failed At + @@ -29,6 +40,13 @@ <% end %> <%= execution.created_at.strftime("%Y-%m-%d %H:%M:%S") %> + + <%= button_to "Retry", retry_failed_job_path(execution), method: :post, + class: "sqd-btn sqd-btn--primary sqd-btn--sm" %> + <%= button_to "Discard", failed_job_path(execution), method: :delete, + class: "sqd-btn sqd-btn--danger sqd-btn--sm", + data: { confirm: "Discard this job?" } %> + <% end %> diff --git a/config/routes.rb b/config/routes.rb index 5c37df7..eb06eb6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,5 +3,13 @@ resources :queues, only: [ :index ] resources :jobs, only: [ :index ] - resources :failed_jobs, only: [ :index ] + resources :failed_jobs, only: [ :index, :destroy ] do + collection do + post :retry_all + post :discard_all + end + member do + post :retry + end + end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 6c850b9..4408cf3 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -6,6 +6,8 @@ require "rspec/rails" +ActionController::Base.allow_forgery_protection = false + # Load solid_queue schema into the test database ActiveRecord::Schema.verbose = false load File.expand_path("dummy/db/schema.rb", __dir__) diff --git a/spec/requests/solid_queue_web/failed_jobs_spec.rb b/spec/requests/solid_queue_web/failed_jobs_spec.rb new file mode 100644 index 0000000..26511d4 --- /dev/null +++ b/spec/requests/solid_queue_web/failed_jobs_spec.rb @@ -0,0 +1,93 @@ +require "rails_helper" + +RSpec.describe "FailedJobs", type: :request do + let(:job) do + SolidQueue::Job.create!( + queue_name: "default", + class_name: "TestJob", + arguments: {}, + active_job_id: SecureRandom.uuid + ) + end + + let!(:execution) do + job.ready_execution&.destroy + SolidQueue::FailedExecution.create!( + job: job, + error: { exception_class: "RuntimeError", message: "boom", backtrace: [] } + ) + end + + describe "GET /jobs/failed_jobs" do + it "returns HTTP success" do + get "/jobs/failed_jobs" + expect(response).to have_http_status(:ok) + end + + it "displays failed job class name" do + get "/jobs/failed_jobs" + expect(response.body).to include("TestJob") + end + end + + describe "POST /jobs/failed_jobs/:id/retry" do + it "retries the job and redirects" do + post "/jobs/failed_jobs/#{execution.id}/retry" + expect(response).to redirect_to("/jobs/failed_jobs") + follow_redirect! + expect(response.body).to include("queued for retry") + end + + it "removes the failed execution" do + expect { + post "/jobs/failed_jobs/#{execution.id}/retry" + }.to change(SolidQueue::FailedExecution, :count).by(-1) + end + end + + describe "DELETE /jobs/failed_jobs/:id" do + it "discards the job and redirects" do + delete "/jobs/failed_jobs/#{execution.id}" + expect(response).to redirect_to("/jobs/failed_jobs") + follow_redirect! + expect(response.body).to include("discarded") + end + + it "removes the failed execution and job" do + expect { + delete "/jobs/failed_jobs/#{execution.id}" + }.to change(SolidQueue::FailedExecution, :count).by(-1) + .and change(SolidQueue::Job, :count).by(-1) + end + end + + describe "POST /jobs/failed_jobs/retry_all" do + it "retries all failed jobs and redirects" do + post "/jobs/failed_jobs/retry_all" + expect(response).to redirect_to("/jobs/failed_jobs") + follow_redirect! + expect(response.body).to include("queued for retry") + end + + it "clears all failed executions" do + expect { + post "/jobs/failed_jobs/retry_all" + }.to change(SolidQueue::FailedExecution, :count).to(0) + end + end + + describe "POST /jobs/failed_jobs/discard_all" do + it "discards all failed jobs and redirects" do + post "/jobs/failed_jobs/discard_all" + expect(response).to redirect_to("/jobs/failed_jobs") + follow_redirect! + expect(response.body).to include("discarded") + end + + it "clears all failed executions and jobs" do + expect { + post "/jobs/failed_jobs/discard_all" + }.to change(SolidQueue::FailedExecution, :count).to(0) + end + end +end