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 @@ +
+
+
+ <%= link_to "Queues", queues_path %> › <%= @queue %> +
+

Jobs

+
+
+ +<%= turbo_frame_tag "jobs-table", data: { turbo_action: "advance" } do %> +<% discardable = SolidQueueWeb::Job::DISCARDABLE.include?(@status) %> + +
+
+ <%= link_to "Ready", queue_jobs_path(queue_name: @queue, status: "ready", q: @search), class: @status == "ready" ? "active" : "" %> + <%= link_to "Scheduled", queue_jobs_path(queue_name: @queue, status: "scheduled", q: @search), class: @status == "scheduled" ? "active" : "" %> + <%= link_to "Running", queue_jobs_path(queue_name: @queue, status: "claimed", q: @search), class: @status == "claimed" ? "active" : "" %> + <%= link_to "Blocked", queue_jobs_path(queue_name: @queue, status: "blocked", q: @search), class: @status == "blocked" ? "active" : "" %> + <%= link_to "Failed", queue_jobs_path(queue_name: @queue, status: "failed", q: @search), class: @status == "failed" ? "active" : "" %> +
+ <% if discardable && @jobs.any? %> +
+ <%= button_to "Discard All", discard_all_queue_jobs_path(queue_name: @queue), + method: :post, + params: { status: @status }, + class: "sqd-btn sqd-btn--danger", + data: { confirm: "Discard all #{@jobs.size} #{@status} jobs in #{@queue}? This cannot be undone." } %> +
+ <% end %> +
+ + + +
+ <% if @jobs.empty? %> +
No <%= @status %> jobs in <%= @queue %>.
+ <% else %> + + + + + + + + <% if discardable %><% end %> + + + + <% @jobs.each do |execution| %> + <% job = execution.job %> + + + + + + <% if discardable %> + + <% end %> + + <% end %> + +
Job ClassPriorityScheduled AtEnqueued AtActions
+ <%= @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") %> + <%= 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 %> +
+ +<% 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