From 8ea0bfc30e29d5253bcd27db751ed2bb2dc84f6e Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Tue, 19 May 2026 09:08:23 -0400 Subject: [PATCH 1/2] Linting cleanup --- app/models/solid_queue_web/job.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/solid_queue_web/job.rb b/app/models/solid_queue_web/job.rb index 6281f26..6cc2f57 100644 --- a/app/models/solid_queue_web/job.rb +++ b/app/models/solid_queue_web/job.rb @@ -10,4 +10,4 @@ class Job "failed" => SolidQueue::FailedExecution }.freeze end -end \ No newline at end of file +end From 010651718d94d056548f3e9aa91e6526ca19e4e4 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Tue, 19 May 2026 09:22:44 -0400 Subject: [PATCH 2/2] feat: failed jobs search/queue filter, scoped bulk actions, blocked reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FailedJobsController: add queue and class name search filters; scope Retry All / Discard All to the active filter (uses discard_all_from_jobs instead of discard_all_in_batches so queue/search scoping works); redirect preserves filter params - Failed jobs index: search form, clickable queue links, queue filter indicator — mirrors the jobs list UX - Jobs show: surface BlockedExecution#expires_at as "Blocked Until" when the job has a blocked execution - Tests: 68 examples, 100% coverage Co-Authored-By: Claude Sonnet 4.6 --- .../solid_queue_web/failed_jobs_controller.rb | 33 ++++-- .../solid_queue_web/jobs_controller.rb | 1 + .../failed_jobs/index.html.erb | 37 +++++- app/views/solid_queue_web/jobs/show.html.erb | 5 + .../solid_queue_web/failed_jobs_spec.rb | 110 ++++++++++++++++++ spec/requests/solid_queue_web/jobs_spec.rb | 14 +++ 6 files changed, 187 insertions(+), 13 deletions(-) diff --git a/app/controllers/solid_queue_web/failed_jobs_controller.rb b/app/controllers/solid_queue_web/failed_jobs_controller.rb index 67dc89e..989706f 100644 --- a/app/controllers/solid_queue_web/failed_jobs_controller.rb +++ b/app/controllers/solid_queue_web/failed_jobs_controller.rb @@ -1,9 +1,9 @@ module SolidQueueWeb class FailedJobsController < ApplicationController + before_action :set_filter_params, only: [ :index, :retry_all, :discard_all ] + def index - @pagy, @failed_jobs = pagy( - SolidQueue::FailedExecution.includes(:job).order(created_at: :desc) - ) + @pagy, @failed_jobs = pagy(filtered_scope.order(created_at: :desc)) end def retry @@ -23,16 +23,31 @@ def destroy end def retry_all - executions = SolidQueue::FailedExecution.includes(:job).to_a - jobs = executions.map(&:job) + jobs = filtered_scope.map(&:job) SolidQueue::FailedExecution.retry_all(jobs) - redirect_to failed_jobs_path, notice: "#{jobs.size} #{"job".pluralize(jobs.size)} queued for retry." + redirect_to failed_jobs_path(queue: @queue, q: @search), + 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." + jobs = filtered_scope.map(&:job) + SolidQueue::FailedExecution.discard_all_from_jobs(jobs) + redirect_to failed_jobs_path(queue: @queue, q: @search), + notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded." + end + + private + + def set_filter_params + @queue = params[:queue].presence + @search = params[:q].presence + end + + def filtered_scope + scope = SolidQueue::FailedExecution.includes(:job) + scope = scope.references(:job).where(solid_queue_jobs: { queue_name: @queue }) if @queue.present? + scope = scope.references(:job).where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.present? + scope end end end diff --git a/app/controllers/solid_queue_web/jobs_controller.rb b/app/controllers/solid_queue_web/jobs_controller.rb index 12395f5..eeca58d 100644 --- a/app/controllers/solid_queue_web/jobs_controller.rb +++ b/app/controllers/solid_queue_web/jobs_controller.rb @@ -17,6 +17,7 @@ def show .includes(:ready_execution, :scheduled_execution, :claimed_execution, :blocked_execution, :failed_execution) .find(params[:id]) @failed_execution = @job.failed_execution + @blocked_execution = @job.blocked_execution @execution_status = derive_status(@job) 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 28b9d24..475501d 100644 --- a/app/views/solid_queue_web/failed_jobs/index.html.erb +++ b/app/views/solid_queue_web/failed_jobs/index.html.erb @@ -2,14 +2,33 @@

Failed Jobs

<% if @failed_jobs.any? %>
- <%= button_to "Retry All", retry_all_failed_jobs_path, method: :post, class: "sqd-btn sqd-btn--primary", + <%= button_to "Retry All", retry_all_failed_jobs_path, + method: :post, + params: { queue: @queue, q: @search }, + 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", + <%= button_to "Discard All", discard_all_failed_jobs_path, + method: :post, + params: { queue: @queue, q: @search }, + class: "sqd-btn sqd-btn--danger", data: { confirm: "Discard all #{@failed_jobs.size} failed jobs? This cannot be undone." } %>
<% end %> + + <% if @pagy.last > 1 %> <%= @pagy.series_nav.html_safe %> <% end %> @@ -33,7 +52,10 @@ <% job = execution.job %> <%= link_to job.class_name, job_path(job) %> - <%= job.queue_name %> + + <%= link_to job.queue_name, failed_jobs_path(queue: job.queue_name, q: @search), + class: "sqd-mono", style: "color: inherit;" %> + <% if execution.exception_class.present? %>
@@ -56,4 +78,11 @@ <% end %> -
\ No newline at end of file + + +<% if @queue.present? %> +

+ Filtering by queue: <%= @queue %> — + <%= link_to "Clear filter", failed_jobs_path(q: @search) %> +

+<% end %> \ No newline at end of file diff --git a/app/views/solid_queue_web/jobs/show.html.erb b/app/views/solid_queue_web/jobs/show.html.erb index 03420d8..3dccc52 100644 --- a/app/views/solid_queue_web/jobs/show.html.erb +++ b/app/views/solid_queue_web/jobs/show.html.erb @@ -45,6 +45,11 @@
Concurrency Key
<%= @job.concurrency_key.presence || "—" %>
+ <% if @blocked_execution %> +
Blocked Until
+
<%= @blocked_execution.expires_at ? @blocked_execution.expires_at.strftime("%Y-%m-%d %H:%M:%S %Z") : "—" %>
+ <% end %> +
Enqueued At
<%= @job.created_at.strftime("%Y-%m-%d %H:%M:%S %Z") %>
diff --git a/spec/requests/solid_queue_web/failed_jobs_spec.rb b/spec/requests/solid_queue_web/failed_jobs_spec.rb index a2d6ff0..f4ef3eb 100644 --- a/spec/requests/solid_queue_web/failed_jobs_spec.rb +++ b/spec/requests/solid_queue_web/failed_jobs_spec.rb @@ -28,6 +28,72 @@ get "/jobs/failed_jobs" expect(response.body).to include("TestJob") end + + it "renders a search form" do + get "/jobs/failed_jobs" + expect(response.body).to include("Filter by job class") + end + end + + describe "GET /jobs/failed_jobs?q= (class name search)" do + let!(:other_job) do + j = SolidQueue::Job.create!( + queue_name: "default", + class_name: "MailerJob", + arguments: {}, + active_job_id: SecureRandom.uuid + ) + j.ready_execution&.destroy + SolidQueue::FailedExecution.create!( + job: j, + error: { exception_class: "RuntimeError", message: "oops", backtrace: [] } + ) + j + end + + it "returns only matching jobs" do + get "/jobs/failed_jobs", params: { q: "Test" } + expect(response.body).to include("TestJob") + expect(response.body).not_to include("MailerJob") + end + + it "renders a clear link when search is active" do + get "/jobs/failed_jobs", params: { q: "Test" } + expect(response.body).to include("Clear") + end + end + + describe "GET /jobs/failed_jobs?queue= (queue filter)" do + let!(:other_job) do + j = SolidQueue::Job.create!( + queue_name: "mailers", + class_name: "MailerJob", + arguments: {}, + active_job_id: SecureRandom.uuid + ) + j.ready_execution&.destroy + SolidQueue::FailedExecution.create!( + job: j, + error: { exception_class: "RuntimeError", message: "oops", backtrace: [] } + ) + j + end + + it "returns only jobs in the specified queue" do + get "/jobs/failed_jobs", params: { queue: "default" } + expect(response.body).to include("TestJob") + expect(response.body).not_to include("MailerJob") + end + + it "shows queue filter indicator" do + get "/jobs/failed_jobs", params: { queue: "default" } + expect(response.body).to include("Filtering by queue") + end + + it "queue names in the table link to the queue filter" do + get "/jobs/failed_jobs" + expect(response.body).to include("queue=default") + end end describe "POST /jobs/failed_jobs/:id/retry" do @@ -90,6 +156,28 @@ post "/jobs/failed_jobs/retry_all" }.to change(SolidQueue::FailedExecution, :count).to(0) end + + it "scopes retry to the specified queue" do + other_job = SolidQueue::Job.create!( + queue_name: "mailers", + class_name: "MailerJob", + arguments: {}, + active_job_id: SecureRandom.uuid + ) + other_job.ready_execution&.destroy + SolidQueue::FailedExecution.create!( + job: other_job, + error: { exception_class: "RuntimeError", message: "oops", backtrace: [] } + ) + + post "/jobs/failed_jobs/retry_all", params: { queue: "default" } + expect(SolidQueue::FailedExecution.joins(:job).where(solid_queue_jobs: { queue_name: "mailers" }).count).to eq(1) + end + + it "preserves queue param in redirect" do + post "/jobs/failed_jobs/retry_all", params: { queue: "default" } + expect(response).to redirect_to("/jobs/failed_jobs?queue=default") + end end describe "POST /jobs/failed_jobs/discard_all" do @@ -105,5 +193,27 @@ post "/jobs/failed_jobs/discard_all" }.to change(SolidQueue::FailedExecution, :count).to(0) end + + it "scopes discard to the specified queue" do + other_job = SolidQueue::Job.create!( + queue_name: "mailers", + class_name: "MailerJob", + arguments: {}, + active_job_id: SecureRandom.uuid + ) + other_job.ready_execution&.destroy + SolidQueue::FailedExecution.create!( + job: other_job, + error: { exception_class: "RuntimeError", message: "oops", backtrace: [] } + ) + + post "/jobs/failed_jobs/discard_all", params: { queue: "default" } + expect(SolidQueue::FailedExecution.joins(:job).where(solid_queue_jobs: { queue_name: "mailers" }).count).to eq(1) + end + + it "preserves queue param in redirect" do + post "/jobs/failed_jobs/discard_all", params: { queue: "default" } + expect(response).to redirect_to("/jobs/failed_jobs?queue=default") + end end end diff --git a/spec/requests/solid_queue_web/jobs_spec.rb b/spec/requests/solid_queue_web/jobs_spec.rb index ed5a243..b8ccc7f 100644 --- a/spec/requests/solid_queue_web/jobs_spec.rb +++ b/spec/requests/solid_queue_web/jobs_spec.rb @@ -52,6 +52,20 @@ get "/jobs/list/#{ready_job.id}" expect(response).to have_http_status(:ok) end + + it "shows blocked until date for blocked jobs" do + ready_job.ready_execution&.destroy + ready_job.update!(concurrency_key: "TestJob/1") + allow_any_instance_of(SolidQueue::BlockedExecution).to receive(:set_expires_at) + SolidQueue::BlockedExecution.create!( + job: ready_job, + queue_name: ready_job.queue_name, + priority: ready_job.priority, + expires_at: 1.hour.from_now + ) + get "/jobs/list/#{ready_job.id}" + expect(response.body).to include("Blocked Until") + end end describe "GET /jobs/list" do