diff --git a/CHANGELOG.md b/CHANGELOG.md
index 275dc7c..8e9cc21 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- **Bulk selection and discard** — checkbox column on the jobs list for ready, scheduled, and blocked statuses; "Discard Selected" submits only the checked jobs via `DELETE /jobs/selection` (`Jobs::SelectionsController#destroy`); "Select All" header checkbox toggles all rows; filter state is preserved in the redirect after a bulk discard
- **Discard All** — "Discard All (N)" button on the jobs index header discards every job matching the current filters (class, queue, priority, period) in one request; respects the discardable-status guard so claimed jobs cannot be bulk-discarded; route `POST /jobs/discard_all` merges into the existing `destroy` action branching on `params[:id]`
- **CSV export** — "Export CSV" button on jobs and failed-jobs index pages; export respects active filters so operators download exactly what they see on screen; columns: `id, class_name, queue_name, status, priority, enqueued_at` for jobs and `id, class_name, queue_name, error_class, error_message, failed_at` for failed jobs
diff --git a/README.md b/README.md
index 1e8f85b..a931c5d 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ A mountable Rails engine that provides a unified web dashboard for the full [Sol
## Features
- **Overview dashboard** with live counts across all three Solid Stack components; cards are clickable and link directly to each section
-- **Solid Queue** — browse jobs by status (ready, scheduled, claimed, blocked) with filtering by job class, queue name, priority, and time period; manage failed jobs (retry / discard), pause/resume queues, and inspect worker processes; **Discard All** bulk-discards every job matching the current filters in one request; **CSV export** downloads jobs or failed jobs as a CSV file respecting active filters
+- **Solid Queue** — browse jobs by status (ready, scheduled, claimed, blocked) with filtering by job class, queue name, priority, and time period; manage failed jobs (retry / discard), pause/resume queues, and inspect worker processes; **Bulk selection** checkbox-selects individual jobs for discard; **Discard All** bulk-discards every job matching the current filters in one request; **CSV export** downloads jobs or failed jobs as a CSV file respecting active filters
- **Job detail page** — drill into any job to see full arguments (pretty-printed JSON), queue, priority, enqueued time, Active Job ID, concurrency key, scheduled/blocked-until metadata, and a Discard button
- **Solid Cache** — entry count and total byte size at a glance
- **Solid Cable** — active message count and distinct channel count
diff --git a/ROADMAP.md b/ROADMAP.md
index d5b3f9f..6bf9991 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -11,8 +11,6 @@ The path to v1.0.0 is staged: first achieve feature parity with `solid_queue_das
> _Make the job management layer genuinely useful for operators._
### Added
-- **Bulk selection** — checkbox-driven multi-select on the jobs and failed-jobs lists
-- **Bulk discard** — discard all selected jobs in a single request
- **Bulk retry (failed jobs)** — retry selected failed jobs with optional stagger interval (5 s / 10 s / 30 s / 1 m) to avoid thundering-herd restarts
- **Edit arguments & retry** — inline argument editor on failed job detail; retry with modified payload
diff --git a/app/controllers/solid_stack_web/jobs/selections_controller.rb b/app/controllers/solid_stack_web/jobs/selections_controller.rb
new file mode 100644
index 0000000..e31dc35
--- /dev/null
+++ b/app/controllers/solid_stack_web/jobs/selections_controller.rb
@@ -0,0 +1,24 @@
+module SolidStackWeb
+ module Jobs
+ class SelectionsController < ApplicationController
+ def destroy
+ status = params[:status].presence_in(Job::STATUSES) || "ready"
+ raise ArgumentError, "Cannot discard #{status} jobs." unless Job::DISCARDABLE.include?(status)
+
+ ids = Array(params[:job_ids]).map(&:to_i).reject(&:zero?)
+ job_ids = Job::EXECUTION_MODELS[status].where(id: ids).pluck(:job_id)
+ SolidQueue::Job.where(id: job_ids).destroy_all
+
+ redirect_to jobs_path(
+ status: status,
+ q: params[:q].presence,
+ queue: params[:queue].presence,
+ period: params[:period].presence_in(PERIOD_DURATIONS.keys),
+ priority: params[:priority].presence
+ )
+ rescue ArgumentError => e
+ redirect_to jobs_path(status: params[:status]), alert: e.message
+ end
+ end
+ end
+end
diff --git a/app/controllers/solid_stack_web/jobs_controller.rb b/app/controllers/solid_stack_web/jobs_controller.rb
index 08eea37..718124c 100644
--- a/app/controllers/solid_stack_web/jobs_controller.rb
+++ b/app/controllers/solid_stack_web/jobs_controller.rb
@@ -2,7 +2,7 @@ module SolidStackWeb
class JobsController < ApplicationController
before_action :set_status
before_action :set_filters, only: [:index, :destroy]
- before_action :require_discardable, only: :destroy
+ before_action :require_discardable, only: [:destroy]
def index
@queue_options = Job::EXECUTION_MODELS[@status].joins(:job).distinct.pluck("solid_queue_jobs.queue_name").sort
diff --git a/app/views/solid_stack_web/jobs/index.html.erb b/app/views/solid_stack_web/jobs/index.html.erb
index 2c1a66a..f0f73e6 100644
--- a/app/views/solid_stack_web/jobs/index.html.erb
+++ b/app/views/solid_stack_web/jobs/index.html.erb
@@ -61,39 +61,65 @@
+ <%== pagy_nav(@pagy) if @pagy.pages > 1 %>
+ <% end %>
<% else %>
<%= render "empty" %>
<% end %>
diff --git a/config/routes.rb b/config/routes.rb
index 5d308a3..2dd70bf 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,8 +1,12 @@
SolidStackWeb::Engine.routes.draw do
root to: "dashboard#index"
+ resource :job_selection, path: "jobs/selection", only: [:destroy], controller: "jobs/selections"
+
resources :jobs, only: [:index, :show, :destroy] do
- collection { post :discard_all, action: :destroy }
+ collection do
+ post :discard_all, action: :destroy
+ end
end
resources :failed_jobs, only: [:index, :destroy] do
diff --git a/spec/requests/solid_stack_web/jobs_spec.rb b/spec/requests/solid_stack_web/jobs_spec.rb
index de3b85d..294b44e 100644
--- a/spec/requests/solid_stack_web/jobs_spec.rb
+++ b/spec/requests/solid_stack_web/jobs_spec.rb
@@ -86,7 +86,7 @@ def create_ready(class_name: "MyJob", queue_name: "default", priority: 0)
get "#{engine_root}/jobs"
- expect(response.body).not_to include('name="queue"')
+ expect(response.body).not_to include('aria-label="Filter by queue"')
end
end
@@ -260,6 +260,46 @@ def create_ready(class_name: "MyJob", queue_name: "default", priority: 0)
end
end
+ describe "DELETE /jobs/selection" do
+ it "destroys only the selected jobs and redirects" do
+ job_a = create_ready(class_name: "JobA")
+ job_b = create_ready(class_name: "JobB")
+
+ delete "#{engine_root}/jobs/selection",
+ params: { status: "ready", job_ids: [job_a.ready_execution.id] }
+
+ expect(response).to redirect_to("#{engine_root}/jobs?status=ready")
+ expect(SolidQueue::Job.exists?(job_a.id)).to be false
+ expect(SolidQueue::Job.exists?(job_b.id)).to be true
+ end
+
+ it "preserves filter params in the redirect" do
+ job = create_ready(queue_name: "reports")
+
+ delete "#{engine_root}/jobs/selection",
+ params: { status: "ready", queue: "reports", q: "Report", period: "1h",
+ job_ids: [job.ready_execution.id] }
+
+ expect(response.location).to include("queue=reports")
+ expect(response.location).to include("q=Report")
+ expect(response.location).to include("period=1h")
+ end
+
+ it "is a no-op when no job_ids are submitted" do
+ create_ready(class_name: "JobA")
+
+ delete "#{engine_root}/jobs/selection", params: { status: "ready", job_ids: [] }
+
+ expect(SolidQueue::ReadyExecution.count).to eq(1)
+ end
+
+ it "redirects when status is not discardable" do
+ delete "#{engine_root}/jobs/selection", params: { status: "claimed" }
+
+ expect(response).to redirect_to("#{engine_root}/jobs?status=claimed")
+ end
+ end
+
describe "POST /jobs/discard_all" do
it "destroys all jobs in the current status and redirects" do
create_ready(class_name: "JobA")
@@ -349,8 +389,8 @@ def create_ready(class_name: "MyJob", queue_name: "default", priority: 0)
expect(response.body).to include("ReportJob")
expect(response.body).not_to include("CleanupJob")
- # The default-queue ReportJob should be excluded — only one match in the reports queue
- expect(response.body.scan("ReportJob").length).to eq(1)
+ # Exactly one job row — only the reports-queue ReportJob
+ expect(response.body.scan('