From 1a54b37c6368470766507a03bc6f8dfa24476bb3 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Mon, 18 May 2026 13:13:39 -0400 Subject: [PATCH] Add discard actions for ready, scheduled, and blocked jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DELETE /jobs/:id discards a single execution via Execution#discard - POST /jobs/discard_all bulk-discards all jobs for the current status/queue - Discard button shown per row; Discard All button in page header - Actions restricted to ready/scheduled/blocked — claimed jobs are actively running and failed jobs have their own dedicated page - Request specs covering single discard, bulk discard, and guard against discarding claimed jobs (8 examples) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 2 + .../solid_queue_web/jobs_controller.rb | 36 ++++++++++ app/views/solid_queue_web/jobs/index.html.erb | 27 ++++++- config/routes.rb | 6 +- spec/requests/solid_queue_web/jobs_spec.rb | 71 +++++++++++++++++++ 5 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 spec/requests/solid_queue_web/jobs_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 0930c46..385f438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Retry and discard actions on individual failed jobs - Bulk "Retry All" and "Discard All" actions for failed jobs +- Discard action on individual ready, scheduled, and blocked jobs +- Bulk "Discard All" action for ready, scheduled, and blocked jobs (scoped to current queue filter) - Roadmap section added to README ### Fixed diff --git a/app/controllers/solid_queue_web/jobs_controller.rb b/app/controllers/solid_queue_web/jobs_controller.rb index a67f71f..a5a5b44 100644 --- a/app/controllers/solid_queue_web/jobs_controller.rb +++ b/app/controllers/solid_queue_web/jobs_controller.rb @@ -1,6 +1,7 @@ module SolidQueueWeb class JobsController < ApplicationController STATUSES = %w[ready scheduled claimed blocked failed].freeze + DISCARDABLE = %w[ready scheduled blocked].freeze def index @status = params[:status].presence_in(STATUSES) || "ready" @@ -17,5 +18,40 @@ def index @jobs = @jobs.where(jobs: { queue_name: @queue }) if @queue.present? @jobs = @jobs.order(created_at: :desc).limit(100) end + + def destroy + execution = execution_model_for!(params[:status]).find(params[:id]) + execution.discard + redirect_to jobs_path(status: params[:status], queue: params[:queue]), notice: "Job discarded." + rescue ArgumentError => e + redirect_to jobs_path(status: params[:status], queue: params[:queue]), alert: e.message + rescue => e + redirect_to jobs_path(status: params[:status], queue: params[:queue]), alert: "Could not discard job: #{e.message}" + end + + def discard_all + model = execution_model_for!(params[:status]) + scope = model.includes(:job) + scope = scope.where(jobs: { queue_name: params[:queue] }) if params[:queue].present? + jobs = scope.map(&:job) + model.discard_all_from_jobs(jobs) + redirect_to jobs_path(status: params[:status], queue: params[:queue]), + notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded." + rescue ArgumentError => e + redirect_to jobs_path(status: params[:status], queue: params[:queue]), alert: e.message + rescue => e + redirect_to jobs_path(status: params[:status], queue: params[:queue]), alert: "Could not discard jobs: #{e.message}" + end + + private + + def execution_model_for!(status) + case status + when "ready" then SolidQueue::ReadyExecution + when "scheduled" then SolidQueue::ScheduledExecution + when "blocked" then SolidQueue::BlockedExecution + else raise ArgumentError, "Cannot discard #{status} jobs from this page." + end + end end end diff --git a/app/views/solid_queue_web/jobs/index.html.erb b/app/views/solid_queue_web/jobs/index.html.erb index a544d22..d4cb25f 100644 --- a/app/views/solid_queue_web/jobs/index.html.erb +++ b/app/views/solid_queue_web/jobs/index.html.erb @@ -1,4 +1,17 @@ -

Jobs

+<% discardable = SolidQueueWeb::JobsController::DISCARDABLE.include?(@status) %> + +
+

Jobs

+ <% if discardable && @jobs.any? %> +
+ <%= button_to "Discard All", discard_all_jobs_path, + method: :post, + params: { status: @status, queue: @queue }, + class: "sqd-btn sqd-btn--danger", + data: { confirm: "Discard all #{@jobs.size} #{@status} jobs? This cannot be undone." } %> +
+ <% end %> +
<%= link_to "Ready", jobs_path(status: "ready", queue: @queue), class: @status == "ready" ? "active" : "" %> @@ -20,6 +33,7 @@ Priority Scheduled At Enqueued At + <% if discardable %><% end %> @@ -39,6 +53,15 @@ <%= job.scheduled_at ? job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S") : "—" %> <%= job.created_at.strftime("%Y-%m-%d %H:%M:%S") %> + <% if discardable %> + + <%= button_to "Discard", job_path(execution), + method: :delete, + params: { status: @status, queue: @queue }, + class: "sqd-btn sqd-btn--danger sqd-btn--sm", + data: { confirm: "Discard this job?" } %> + + <% end %> <% end %> @@ -51,4 +74,4 @@ Filtering by queue: <%= @queue %> — <%= link_to "Clear filter", jobs_path(status: @status) %>

-<% end %> +<% end %> \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index eb06eb6..c1c2828 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,7 +2,11 @@ root to: "dashboard#index" resources :queues, only: [ :index ] - resources :jobs, only: [ :index ] + resources :jobs, only: [ :index, :destroy ] do + collection do + post :discard_all + end + end resources :failed_jobs, only: [ :index, :destroy ] do collection do post :retry_all diff --git a/spec/requests/solid_queue_web/jobs_spec.rb b/spec/requests/solid_queue_web/jobs_spec.rb new file mode 100644 index 0000000..39bb41a --- /dev/null +++ b/spec/requests/solid_queue_web/jobs_spec.rb @@ -0,0 +1,71 @@ +require "rails_helper" + +RSpec.describe "Jobs", type: :request do + let!(:ready_job) do + SolidQueue::Job.create!( + queue_name: "default", + class_name: "TestJob", + arguments: {}, + active_job_id: SecureRandom.uuid + ) + end + + let(:ready_execution) { ready_job.ready_execution } + + describe "GET /jobs/jobs" do + it "returns HTTP success" do + get "/jobs/jobs" + expect(response).to have_http_status(:ok) + end + + it "shows ready jobs by default" do + get "/jobs/jobs" + expect(response.body).to include("TestJob") + end + end + + describe "DELETE /jobs/jobs/:id (discard single)" do + it "discards the job and redirects" do + delete "/jobs/jobs/#{ready_execution.id}", params: { status: "ready" } + expect(response).to redirect_to("/jobs/jobs?status=ready") + follow_redirect! + expect(response.body).to include("discarded") + end + + it "removes the execution and job" do + expect { + delete "/jobs/jobs/#{ready_execution.id}", params: { status: "ready" } + }.to change(SolidQueue::ReadyExecution, :count).by(-1) + .and change(SolidQueue::Job, :count).by(-1) + end + + it "rejects discard for claimed status" do + delete "/jobs/jobs/#{ready_execution.id}", params: { status: "claimed" } + expect(response).to redirect_to("/jobs/jobs?status=claimed") + follow_redirect! + expect(response.body).to include("Cannot discard") + end + end + + describe "POST /jobs/jobs/discard_all" do + it "discards all ready jobs and redirects" do + post "/jobs/jobs/discard_all", params: { status: "ready" } + expect(response).to redirect_to("/jobs/jobs?status=ready") + follow_redirect! + expect(response.body).to include("discarded") + end + + it "clears all ready executions" do + expect { + post "/jobs/jobs/discard_all", params: { status: "ready" } + }.to change(SolidQueue::ReadyExecution, :count).to(0) + end + + it "rejects discard_all for claimed status" do + post "/jobs/jobs/discard_all", params: { status: "claimed" } + expect(response).to redirect_to("/jobs/jobs?status=claimed") + follow_redirect! + expect(response.body).to include("Cannot discard") + end + end +end