diff --git a/app/controllers/solid_queue_web/jobs_controller.rb b/app/controllers/solid_queue_web/jobs_controller.rb
index eeca58d..800a9ce 100644
--- a/app/controllers/solid_queue_web/jobs_controller.rb
+++ b/app/controllers/solid_queue_web/jobs_controller.rb
@@ -1,13 +1,11 @@
module SolidQueueWeb
class JobsController < ApplicationController
- before_action :set_status_and_queue, only: [ :destroy, :discard_all ]
+ before_action :set_status, only: [ :destroy, :discard_all ]
def index
@status = params[:status].presence_in(Job::STATUSES) || "ready"
- @queue = params[:queue].presence
@search = params[:q].presence
@jobs = Job::EXECUTION_MODELS[@status].includes(:job)
- @jobs = @jobs.where(jobs: { queue_name: @queue }) if @queue.present?
@jobs = @jobs.references(:job).where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.present?
@pagy, @jobs = pagy(@jobs.order(created_at: :desc))
end
@@ -28,24 +26,24 @@ def destroy
@remaining_count = filtered_scope(model).count
respond_to do |format|
format.turbo_stream
- format.html { redirect_to jobs_path(status: @status, queue: @queue), notice: "Job discarded." }
+ format.html { redirect_to jobs_path(status: @status), notice: "Job discarded." }
end
rescue ArgumentError => e
- redirect_to jobs_path(status: @status, queue: @queue), alert: e.message
+ redirect_to jobs_path(status: @status), alert: e.message
rescue => e
- redirect_to jobs_path(status: @status, queue: @queue), alert: "Could not discard job: #{e.message}"
+ redirect_to jobs_path(status: @status), alert: "Could not discard job: #{e.message}"
end
def discard_all
model = execution_model_for!(@status)
jobs = filtered_scope(model).map(&:job)
model.discard_all_from_jobs(jobs)
- redirect_to jobs_path(status: @status, queue: @queue),
+ redirect_to jobs_path(status: @status),
notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
rescue ArgumentError => e
- redirect_to jobs_path(status: @status, queue: @queue), alert: e.message
+ redirect_to jobs_path(status: @status), alert: e.message
rescue => e
- redirect_to jobs_path(status: @status, queue: @queue), alert: "Could not discard jobs: #{e.message}"
+ redirect_to jobs_path(status: @status), alert: "Could not discard jobs: #{e.message}"
end
private
@@ -59,14 +57,12 @@ def derive_status(job)
"finished"
end
- def set_status_and_queue
+ def set_status
@status = params[:status]
- @queue = params[:queue].presence
end
def filtered_scope(model)
- scope = model.includes(:job)
- @queue.present? ? scope.where(jobs: { queue_name: @queue }) : scope
+ model.includes(:job)
end
def execution_model_for!(status)
diff --git a/app/controllers/solid_queue_web/queues/jobs_controller.rb b/app/controllers/solid_queue_web/queues/jobs_controller.rb
new file mode 100644
index 0000000..41be014
--- /dev/null
+++ b/app/controllers/solid_queue_web/queues/jobs_controller.rb
@@ -0,0 +1,63 @@
+module SolidQueueWeb
+ module Queues
+ class JobsController < ApplicationController
+ before_action :set_queue
+ before_action :set_status, only: [ :destroy, :discard_all ]
+
+ def index
+ @status = params[:status].presence_in(Job::STATUSES) || "ready"
+ @search = params[:q].presence
+ @jobs = Job::EXECUTION_MODELS[@status].includes(:job)
+ .where(solid_queue_jobs: { queue_name: @queue })
+ @jobs = @jobs.references(:job).where("solid_queue_jobs.class_name LIKE ?", "%#{@search}%") if @search.present?
+ @pagy, @jobs = pagy(@jobs.order(created_at: :desc))
+ end
+
+ def destroy
+ model = execution_model_for!(@status)
+ @execution = model.find(params[:id])
+ @execution.discard
+ @remaining_count = filtered_scope(model).count
+ respond_to do |format|
+ format.turbo_stream
+ format.html { redirect_to queue_jobs_path(queue_name: @queue, status: @status), notice: "Job discarded." }
+ end
+ rescue ArgumentError => e
+ redirect_to queue_jobs_path(queue_name: @queue, status: @status), alert: e.message
+ rescue => e
+ redirect_to queue_jobs_path(queue_name: @queue, status: @status), alert: "Could not discard job: #{e.message}"
+ end
+
+ def discard_all
+ model = execution_model_for!(@status)
+ jobs = filtered_scope(model).map(&:job)
+ model.discard_all_from_jobs(jobs)
+ redirect_to queue_jobs_path(queue_name: @queue, status: @status),
+ notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
+ rescue ArgumentError => e
+ redirect_to queue_jobs_path(queue_name: @queue, status: @status), alert: e.message
+ rescue => e
+ redirect_to queue_jobs_path(queue_name: @queue, status: @status), alert: "Could not discard jobs: #{e.message}"
+ end
+
+ private
+
+ def set_queue
+ @queue = params[:queue_name]
+ end
+
+ def set_status
+ @status = params[:status]
+ end
+
+ def filtered_scope(model)
+ model.includes(:job).where(solid_queue_jobs: { queue_name: @queue })
+ end
+
+ def execution_model_for!(status)
+ raise ArgumentError, "Cannot discard #{status} jobs from this page." unless Job::DISCARDABLE.include?(status)
+ Job::EXECUTION_MODELS[status]
+ 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 46dd3f2..323f49e 100644
--- a/app/views/solid_queue_web/jobs/index.html.erb
+++ b/app/views/solid_queue_web/jobs/index.html.erb
@@ -15,7 +15,7 @@
<%= button_to "Discard All", discard_all_jobs_path,
method: :post,
- params: { status: @status, queue: @queue },
+ params: { status: @status },
class: "sqd-btn sqd-btn--danger",
data: { confirm: "Discard all #{@jobs.size} #{@status} jobs? This cannot be undone." } %>
@@ -24,15 +24,12 @@
@@ -57,10 +54,10 @@
|
<%= @status %>
- <%= link_to job.class_name, job_path(job), style: "margin-left: 0.5rem;" %>
+ <%= link_to job.class_name, job_path(job), style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %>
|
- <%= link_to job.queue_name, jobs_path(status: @status, queue: job.queue_name),
+ <%= link_to job.queue_name, queue_jobs_path(queue_name: job.queue_name, status: @status),
class: "sqd-mono", style: "color: inherit;" %>
|
<%= job.priority %> |
@@ -72,7 +69,7 @@
<%= button_to "Discard", job_path(execution),
method: :delete,
- params: { status: @status, queue: @queue },
+ params: { status: @status },
class: "sqd-btn sqd-btn--danger sqd-btn--sm",
data: { confirm: "Discard this job?" } %>
|
@@ -87,11 +84,4 @@
<% if @pagy.last > 1 %>
<%= @pagy.series_nav.html_safe %>
<% end %>
-
-<% if @queue.present? %>
-
- Filtering by queue: <%= @queue %> —
- <%= link_to "Clear filter", jobs_path(status: @status) %>
-
-<% end %>
<% end %>
\ No newline at end of file
diff --git a/app/views/solid_queue_web/queues/jobs/destroy.turbo_stream.erb b/app/views/solid_queue_web/queues/jobs/destroy.turbo_stream.erb
new file mode 100644
index 0000000..d48a584
--- /dev/null
+++ b/app/views/solid_queue_web/queues/jobs/destroy.turbo_stream.erb
@@ -0,0 +1,9 @@
+<% if @remaining_count == 0 %>
+ <%= turbo_stream.replace "jobs-list" do %>
+
+
No <%= @status %> jobs in <%= @queue %>.
+
+ <% end %>
+<% else %>
+ <%= turbo_stream.remove "execution_#{@execution.id}" %>
+<% end %>
\ No newline at end of file
diff --git a/app/views/solid_queue_web/queues/jobs/index.html.erb b/app/views/solid_queue_web/queues/jobs/index.html.erb
new file mode 100644
index 0000000..f7690fe
--- /dev/null
+++ b/app/views/solid_queue_web/queues/jobs/index.html.erb
@@ -0,0 +1,89 @@
+
+
+<%= turbo_frame_tag "jobs-table", data: { turbo_action: "advance" } do %>
+<% discardable = SolidQueueWeb::Job::DISCARDABLE.include?(@status) %>
+
+
+
+
+
+
+ <% if @jobs.empty? %>
+
No <%= @status %> jobs in <%= @queue %>.
+ <% else %>
+
+
+
+ | Job Class |
+ Priority |
+ Scheduled At |
+ Enqueued At |
+ <% if discardable %>Actions | <% end %>
+
+
+
+ <% @jobs.each do |execution| %>
+ <% job = execution.job %>
+
+ |
+ <%= @status %>
+ <%= link_to job.class_name, job_path(job), style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %>
+ |
+ <%= job.priority %> |
+
+ <%= 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", queue_job_path(queue_name: @queue, id: execution),
+ method: :delete,
+ params: { status: @status },
+ class: "sqd-btn sqd-btn--danger sqd-btn--sm",
+ data: { confirm: "Discard this job?" } %>
+ |
+ <% end %>
+
+ <% end %>
+
+
+ <% end %>
+
+
+<% if @pagy.last > 1 %>
+ <%= @pagy.series_nav.html_safe %>
+<% end %>
+<% end %>
\ No newline at end of file
diff --git a/config/routes.rb b/config/routes.rb
index 92bfa7d..72dc845 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -8,6 +8,11 @@
post :pause
post :resume
end
+ resources :jobs, path: "list", only: [ :index, :destroy ], controller: "queues/jobs" do
+ collection do
+ post :discard_all
+ end
+ end
end
resources :jobs, path: "list", only: [ :index, :show, :destroy ] do
collection do
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index 4408cf3..2afeced 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -16,4 +16,5 @@
config.use_transactional_fixtures = true
config.infer_spec_type_from_file_location!
config.filter_rails_from_backtrace!
+ config.filter_gems_from_backtrace("turbo-rails")
end
diff --git a/spec/requests/solid_queue_web/jobs_spec.rb b/spec/requests/solid_queue_web/jobs_spec.rb
index b8ccc7f..3a2cb70 100644
--- a/spec/requests/solid_queue_web/jobs_spec.rb
+++ b/spec/requests/solid_queue_web/jobs_spec.rb
@@ -125,6 +125,7 @@
expect(response.body).to include("discarded")
end
+
it "responds with turbo stream when last job: replaces card with empty state" do
delete "/jobs/list/#{ready_execution.id}",
params: { status: "ready" },
diff --git a/spec/requests/solid_queue_web/queues/jobs_spec.rb b/spec/requests/solid_queue_web/queues/jobs_spec.rb
new file mode 100644
index 0000000..8373544
--- /dev/null
+++ b/spec/requests/solid_queue_web/queues/jobs_spec.rb
@@ -0,0 +1,161 @@
+require "rails_helper"
+
+RSpec.describe "Queues::Jobs", type: :request do
+ let!(:job) do
+ SolidQueue::Job.create!(
+ queue_name: "default",
+ class_name: "TestJob",
+ arguments: {},
+ active_job_id: SecureRandom.uuid
+ )
+ end
+
+ let(:execution) { job.ready_execution }
+
+ describe "GET /jobs/queues/:queue_name/list" do
+ it "returns HTTP success" do
+ get "/jobs/queues/default/list"
+ expect(response).to have_http_status(:ok)
+ end
+
+ it "shows jobs in the specified queue" do
+ get "/jobs/queues/default/list"
+ expect(response.body).to include("TestJob")
+ end
+
+ it "excludes jobs from other queues" do
+ SolidQueue::Job.create!(
+ queue_name: "mailers",
+ class_name: "MailerJob",
+ arguments: {},
+ active_job_id: SecureRandom.uuid
+ )
+ get "/jobs/queues/default/list"
+ expect(response.body).not_to include("MailerJob")
+ end
+
+ it "shows empty state for an empty queue" do
+ get "/jobs/queues/other/list"
+ expect(response.body).to include("No ready jobs in other")
+ end
+
+ it "displays a breadcrumb linking back to queues" do
+ get "/jobs/queues/default/list"
+ expect(response.body).to include("Queues")
+ end
+
+ it "filters by status" do
+ job.ready_execution&.destroy
+ job.update!(scheduled_at: 1.hour.from_now)
+ SolidQueue::ScheduledExecution.create!(
+ job: job, queue_name: job.queue_name, priority: job.priority
+ )
+ get "/jobs/queues/default/list", params: { status: "scheduled" }
+ expect(response.body).to include("TestJob")
+ end
+
+ it "filters by class name search" do
+ SolidQueue::Job.create!(
+ queue_name: "default",
+ class_name: "MailerJob",
+ arguments: {},
+ active_job_id: SecureRandom.uuid
+ )
+ get "/jobs/queues/default/list", 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/queues/default/list", params: { q: "Test" }
+ expect(response.body).to include("Clear")
+ end
+ end
+
+ describe "DELETE /jobs/queues/:queue_name/list/:id" do
+ it "discards the job and redirects" do
+ delete "/jobs/queues/default/list/#{execution.id}", params: { status: "ready" }
+ expect(response).to redirect_to("/jobs/queues/default/list?status=ready")
+ follow_redirect!
+ expect(response.body).to include("discarded")
+ end
+
+ it "removes the execution and job" do
+ expect {
+ delete "/jobs/queues/default/list/#{execution.id}", params: { status: "ready" }
+ }.to change(SolidQueue::ReadyExecution, :count).by(-1)
+ .and change(SolidQueue::Job, :count).by(-1)
+ end
+
+ it "responds with turbo stream: removes row when more jobs remain" do
+ SolidQueue::Job.create!(
+ queue_name: "default", class_name: "OtherJob",
+ arguments: {}, active_job_id: SecureRandom.uuid
+ )
+ delete "/jobs/queues/default/list/#{execution.id}",
+ params: { status: "ready" },
+ headers: { "Accept" => "text/vnd.turbo-stream.html, text/html" }
+ expect(response.content_type).to include("text/vnd.turbo-stream.html")
+ expect(response.body).to include("execution_#{execution.id}")
+ end
+
+ it "responds with turbo stream: replaces card with empty state when last job" do
+ delete "/jobs/queues/default/list/#{execution.id}",
+ params: { status: "ready" },
+ headers: { "Accept" => "text/vnd.turbo-stream.html, text/html" }
+ expect(response.content_type).to include("text/vnd.turbo-stream.html")
+ expect(response.body).to include("sqd-empty")
+ expect(response.body).to include("No ready jobs in default")
+ end
+
+ it "rejects discard of claimed jobs" do
+ delete "/jobs/queues/default/list/#{execution.id}", params: { status: "claimed" }
+ expect(response).to redirect_to("/jobs/queues/default/list?status=claimed")
+ follow_redirect!
+ expect(response.body).to include("Cannot discard")
+ end
+
+ it "handles unexpected errors gracefully" do
+ allow_any_instance_of(SolidQueue::ReadyExecution).to receive(:discard).and_raise(RuntimeError, "disk full")
+ delete "/jobs/queues/default/list/#{execution.id}", params: { status: "ready" }
+ expect(response).to redirect_to("/jobs/queues/default/list?status=ready")
+ follow_redirect!
+ expect(response.body).to include("Could not discard job")
+ end
+ end
+
+ describe "POST /jobs/queues/:queue_name/list/discard_all" do
+ it "discards all ready jobs in the queue and redirects" do
+ post "/jobs/queues/default/list/discard_all", params: { status: "ready" }
+ expect(response).to redirect_to("/jobs/queues/default/list?status=ready")
+ follow_redirect!
+ expect(response.body).to include("discarded")
+ end
+
+ it "clears executions only in the specified queue" do
+ SolidQueue::Job.create!(
+ queue_name: "mailers", class_name: "MailerJob",
+ arguments: {}, active_job_id: SecureRandom.uuid
+ )
+ expect {
+ post "/jobs/queues/default/list/discard_all", params: { status: "ready" }
+ }.to change(SolidQueue::ReadyExecution, :count).by(-1)
+ expect(SolidQueue::ReadyExecution.joins(:job).where(solid_queue_jobs: { queue_name: "mailers" }).count).to eq(1)
+ end
+
+ it "rejects discard_all for claimed status" do
+ post "/jobs/queues/default/list/discard_all", params: { status: "claimed" }
+ expect(response).to redirect_to("/jobs/queues/default/list?status=claimed")
+ follow_redirect!
+ expect(response.body).to include("Cannot discard")
+ end
+
+ it "handles unexpected errors gracefully" do
+ allow(SolidQueue::ReadyExecution).to receive(:discard_all_from_jobs).and_raise(RuntimeError, "disk full")
+ post "/jobs/queues/default/list/discard_all", params: { status: "ready" }
+ expect(response).to redirect_to("/jobs/queues/default/list?status=ready")
+ follow_redirect!
+ expect(response.body).to include("Could not discard jobs")
+ end
+ end
+end