diff --git a/CHANGELOG.md b/CHANGELOG.md index e6a03c1..ac004fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Job selection and targeted bulk actions — checkboxes on the jobs index (ready/scheduled/blocked) and failed jobs index; a selection bar appears above the table showing the count and action buttons ("Discard Selected" for jobs, "Retry Selected" / "Discard Selected" for failed jobs); select-all checkbox in the table header; powered by a new `selection` Stimulus controller that injects checked IDs into a hidden form on submit; uses nested singular resources (`Jobs::SelectionsController`, `FailedJobs::SelectionsController`) following Rails conventions - Auto-refresh for dashboard, jobs list, and processes — a `refresh` Stimulus controller polls the current page at a configurable interval (5 s for dashboard, 10 s for jobs/processes) and swaps the matching `` content in place; polling pauses when the browser tab is hidden and resumes with an immediate refresh when the tab becomes visible again - Time-based period filter (`?period=1h|24h|7d`) on the jobs and failed jobs indexes — filters results by enqueued timestamp; period pills render inline with the search bar, right-justified; active period is preserved across status tab switches, search, queue filters, and bulk actions - Global job search at `/jobs/search` — queries all execution models (ready, scheduled, claimed, blocked, failed) by class name substring; results grouped by status with match count and a "View all →" link; native `` autocomplete pre-populated from all known job class names; auto-submits on datalist selection via the `search` Stimulus controller diff --git a/README.md b/README.md index 64334b1..1a7d80a 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ SolidQueueWeb surfaces all of this in a browser UI available at any route you ch - **Recurring tasks** — all configured recurring tasks with cron schedule, next run time, last run time, and static/dynamic classification - **Processes** — workers, dispatchers, and supervisors with heartbeat health status; auto-refreshes every 10 seconds - **Global search** — search across all job statuses at once by class name substring; results grouped by status with match count and direct links to filtered views; native datalist autocomplete pre-populated from all known job classes; auto-submits on selection +- **Targeted bulk actions** — checkboxes on the jobs and failed jobs lists for selecting individual rows; selection bar shows count and action buttons ("Discard Selected" for jobs, "Retry Selected" / "Discard Selected" for failed jobs); select-all checkbox in the table header ## Screenshots diff --git a/app/assets/stylesheets/solid_queue_web/application.css b/app/assets/stylesheets/solid_queue_web/application.css index e6fa75f..031b15b 100644 --- a/app/assets/stylesheets/solid_queue_web/application.css +++ b/app/assets/stylesheets/solid_queue_web/application.css @@ -302,6 +302,25 @@ tbody tr:hover { background: var(--bg); } .sqd-row-actions { white-space: nowrap; text-align: right; width: 1%; } .sqd-row-actions form { display: inline; margin-left: 0.25rem; } +/* Selection bar */ +.sqd-selection-bar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 1rem; + background: var(--bg); + border-bottom: 1px solid var(--border); + font-size: 13px; +} + +table th input[type="checkbox"], +table td input[type="checkbox"] { + width: 15px; + height: 15px; + cursor: pointer; + accent-color: var(--primary); +} + /* Search */ .sqd-search { display: flex; diff --git a/app/controllers/solid_queue_web/failed_jobs/selections_controller.rb b/app/controllers/solid_queue_web/failed_jobs/selections_controller.rb new file mode 100644 index 0000000..9f237f1 --- /dev/null +++ b/app/controllers/solid_queue_web/failed_jobs/selections_controller.rb @@ -0,0 +1,27 @@ +module SolidQueueWeb + module FailedJobs + class SelectionsController < ApplicationController + def create + ids = Array(params[:ids]).map(&:to_i).reject(&:zero?) + executions = SolidQueue::FailedExecution.where(id: ids) + jobs = executions.includes(:job).map(&:job) + SolidQueue::FailedExecution.retry_all(jobs) + redirect_to failed_jobs_path, + notice: "#{jobs.size} #{"job".pluralize(jobs.size)} queued for retry." + rescue => e + redirect_to failed_jobs_path, alert: "Could not retry jobs: #{e.message}" + end + + def destroy + ids = Array(params[:ids]).map(&:to_i).reject(&:zero?) + executions = SolidQueue::FailedExecution.where(id: ids) + jobs = executions.includes(:job).map(&:job) + SolidQueue::FailedExecution.discard_all_from_jobs(jobs) + redirect_to failed_jobs_path, + notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded." + rescue => e + redirect_to failed_jobs_path, alert: "Could not discard jobs: #{e.message}" + end + end + end +end diff --git a/app/controllers/solid_queue_web/jobs/selections_controller.rb b/app/controllers/solid_queue_web/jobs/selections_controller.rb new file mode 100644 index 0000000..8522d77 --- /dev/null +++ b/app/controllers/solid_queue_web/jobs/selections_controller.rb @@ -0,0 +1,21 @@ +module SolidQueueWeb + module Jobs + class SelectionsController < ApplicationController + def destroy + status = params[:status] + period = params[:period].presence_in(PERIOD_DURATIONS.keys) + raise ArgumentError, "Cannot discard #{status} jobs." unless Job::DISCARDABLE.include?(status) + model = Job::EXECUTION_MODELS[status] + ids = Array(params[:ids]).map(&:to_i).reject(&:zero?) + jobs = model.where(id: ids).includes(:job).map(&:job) + model.discard_all_from_jobs(jobs) + redirect_to jobs_path(status: status, period: period), + notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded." + rescue ArgumentError => e + redirect_to jobs_path(status: status), alert: e.message + rescue => e + redirect_to jobs_path(status: status), alert: "Could not discard jobs: #{e.message}" + end + end + end +end diff --git a/app/controllers/solid_queue_web/jobs_controller.rb b/app/controllers/solid_queue_web/jobs_controller.rb index 6370401..a9498f0 100644 --- a/app/controllers/solid_queue_web/jobs_controller.rb +++ b/app/controllers/solid_queue_web/jobs_controller.rb @@ -1,6 +1,6 @@ module SolidQueueWeb class JobsController < ApplicationController - before_action :set_status, only: [ :destroy, :discard_all ] + before_action :set_status, only: [ :destroy, :discard_all, :discard_selected ] def index @status = params[:status].presence_in(Job::STATUSES) || "ready" diff --git a/app/javascript/solid_queue_web/application.js b/app/javascript/solid_queue_web/application.js index 7e08a27..8c08d47 100644 --- a/app/javascript/solid_queue_web/application.js +++ b/app/javascript/solid_queue_web/application.js @@ -2,7 +2,9 @@ import "@hotwired/turbo" import { Application } from "@hotwired/stimulus" import SearchController from "solid_queue_web/search_controller" import RefreshController from "solid_queue_web/refresh_controller" +import SelectionController from "solid_queue_web/selection_controller" const application = Application.start() application.register("search", SearchController) application.register("refresh", RefreshController) +application.register("selection", SelectionController) diff --git a/app/javascript/solid_queue_web/selection_controller.js b/app/javascript/solid_queue_web/selection_controller.js new file mode 100644 index 0000000..b7790c9 --- /dev/null +++ b/app/javascript/solid_queue_web/selection_controller.js @@ -0,0 +1,42 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["checkbox", "selectAll", "bar", "count"] + + toggle() { + this._update() + } + + selectAll({ target }) { + this.checkboxTargets.forEach(cb => cb.checked = target.checked) + this._update() + } + + submit({ params: { formId } }) { + const form = document.getElementById(formId) + if (!form) return + form.querySelectorAll("[data-injected-id]").forEach(el => el.remove()) + this.checkboxTargets + .filter(cb => cb.checked) + .forEach(cb => { + const input = document.createElement("input") + input.type = "hidden" + input.name = "ids[]" + input.value = cb.value + input.dataset.injectedId = true + form.appendChild(input) + }) + form.requestSubmit() + } + + _update() { + const checked = this.checkboxTargets.filter(cb => cb.checked).length + const total = this.checkboxTargets.length + if (this.hasBarTarget) this.barTarget.style.display = checked > 0 ? "" : "none" + if (this.hasCountTarget) this.countTarget.textContent = checked + if (this.hasSelectAllTarget) { + this.selectAllTarget.indeterminate = checked > 0 && checked < total + this.selectAllTarget.checked = total > 0 && checked === total + } + } +} \ No newline at end of file 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 250330c..0934469 100644 --- a/app/views/solid_queue_web/failed_jobs/index.html.erb +++ b/app/views/solid_queue_web/failed_jobs/index.html.erb @@ -40,52 +40,90 @@ <%= @pagy.series_nav.html_safe %> <% end %> -
- <% if @failed_jobs.empty? %> -
No failed jobs. All clear!
- <% else %> - - - - - - - - - - - - <% @failed_jobs.each do |execution| %> - <% job = execution.job %> +<% if @failed_jobs.any? %> +
+ <%= form_tag failed_job_selection_path, method: :post, id: "retry-selection-form" do %> + <%= hidden_field_tag :queue, @queue %> + <%= hidden_field_tag :q, @search %> + <%= hidden_field_tag :period, @period %> + <% end %> + + <%= form_tag failed_job_selection_path, method: :delete, id: "discard-selection-form", + data: { turbo_confirm: "Discard selected jobs? This cannot be undone." } do %> + <%= hidden_field_tag :queue, @queue %> + <%= hidden_field_tag :q, @search %> + <%= hidden_field_tag :period, @period %> + <% end %> + + + +
+
Job ClassQueueErrorFailed AtActions
+ - - - - - + + + + + + - <% end %> - -
<%= link_to job.class_name, job_path(job) %> - <%= link_to job.queue_name, failed_jobs_path(queue: job.queue_name, q: @search, period: @period), - class: "sqd-mono", style: "color: inherit;" %> - - <% if execution.exception_class.present? %> -
- <%= execution.exception_class %>: <%= execution.message %> -
- <% else %> - - <% end %> -
<%= execution.created_at.strftime("%Y-%m-%d %H:%M:%S") %> - <%= button_to "Retry", retry_failed_job_path(execution), method: :post, - class: "sqd-btn sqd-btn--primary sqd-btn--sm" %> - <%= button_to "Discard", failed_job_path(execution), method: :delete, - class: "sqd-btn sqd-btn--danger sqd-btn--sm", - data: { confirm: "Discard this job?" } %> - + + Job ClassQueueErrorFailed AtActions
- <% end %> -
+ + + <% @failed_jobs.each do |execution| %> + <% job = execution.job %> + + + + + <%= link_to job.class_name, job_path(job) %> + + <%= link_to job.queue_name, failed_jobs_path(queue: job.queue_name, q: @search, period: @period), + class: "sqd-mono", style: "color: inherit;" %> + + + <% if execution.exception_class.present? %> +
+ <%= execution.exception_class %>: <%= execution.message %> +
+ <% else %> + + <% end %> + + <%= execution.created_at.strftime("%Y-%m-%d %H:%M:%S") %> + + <%= button_to "Retry", retry_failed_job_path(execution), method: :post, + class: "sqd-btn sqd-btn--primary sqd-btn--sm" %> + <%= button_to "Discard", failed_job_path(execution), method: :delete, + class: "sqd-btn sqd-btn--danger sqd-btn--sm", + data: { confirm: "Discard this job?" } %> + + + <% end %> + + + + +<% else %> +
+
No failed jobs. All clear!
+
+<% end %> <% if @queue.present? %>

diff --git a/app/views/solid_queue_web/jobs/index.html.erb b/app/views/solid_queue_web/jobs/index.html.erb index 8194148..1a2aaa5 100644 --- a/app/views/solid_queue_web/jobs/index.html.erb +++ b/app/views/solid_queue_web/jobs/index.html.erb @@ -40,39 +40,61 @@ -

- <% if @jobs.empty? %> -
No <%= @status %> jobs.
- <% else %> - - - - - - - - - <% if discardable %><% end %> - - - - <% @jobs.each do |execution| %> - <% job = execution.job %> - - - - - - - <% if discardable %> +<% if discardable && @jobs.any? %> +
+ <%= form_tag job_selection_path, method: :delete, id: "job-selection-form", + data: { turbo_confirm: "Discard selected jobs? This cannot be undone." } do %> + <%= hidden_field_tag :status, @status %> + <%= hidden_field_tag :period, @period %> + <% end %> + + + +
+
Job ClassQueuePriorityScheduled AtEnqueued AtActions
- <%= @status %> - <%= link_to job.class_name, job_path(job), style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %> - - <%= link_to job.queue_name, queue_jobs_path(queue_name: job.queue_name, status: @status), - class: "sqd-mono", style: "color: inherit;" %> - <%= 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") %>
+ + + + + + + + + + + + + <% @jobs.each do |execution| %> + <% job = execution.job %> + + + + + + + - <% end %> + + <% end %> + +
+ + Job ClassQueuePriorityScheduled AtEnqueued AtActions
+ + + <%= @status %> + <%= link_to job.class_name, job_path(job), style: "margin-left: 0.5rem;", data: { turbo_frame: "_top" } %> + + <%= link_to job.queue_name, queue_jobs_path(queue_name: job.queue_name, status: @status), + class: "sqd-mono", style: "color: inherit;" %> + <%= 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", job_path(execution), method: :delete, @@ -80,13 +102,51 @@ class: "sqd-btn sqd-btn--danger sqd-btn--sm", data: { confirm: "Discard this job?" } %>
+
+ +<% else %> +
+ <% if @jobs.empty? %> +
No <%= @status %> jobs.
+ <% else %> + + + + + + + + - <% end %> - -
Job ClassQueuePriorityScheduled AtEnqueued At
- <% 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" } %> + + + <%= link_to job.queue_name, queue_jobs_path(queue_name: job.queue_name, status: @status), + class: "sqd-mono", style: "color: inherit;" %> + + <%= 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") %> + + <% end %> + + + <% end %> + +<% end %> <% if @pagy.last > 1 %> <%= @pagy.series_nav.html_safe %> diff --git a/config/importmap.rb b/config/importmap.rb index ca0bd21..eb2f565 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -1,3 +1,4 @@ pin "solid_queue_web", to: "solid_queue_web/application.js" pin "solid_queue_web/search_controller", to: "solid_queue_web/search_controller.js" pin "solid_queue_web/refresh_controller", to: "solid_queue_web/refresh_controller.js" +pin "solid_queue_web/selection_controller", to: "solid_queue_web/selection_controller.js" diff --git a/config/routes.rb b/config/routes.rb index 4ec1be0..79b6c53 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,11 +16,18 @@ end end end + + # Singular selection resources must be defined before the member routes of their + # parent resources, otherwise DELETE /list/selection matches /list/:id first. + resource :job_selection, path: "list/selection", only: [ :destroy ], controller: "jobs/selections" resources :jobs, path: "list", only: [ :index, :show, :destroy ] do collection do post :discard_all end end + + resource :failed_job_selection, path: "failed_jobs/selection", only: [ :create, :destroy ], + controller: "failed_jobs/selections" resources :failed_jobs, only: [ :index, :destroy ] do collection do post :retry_all diff --git a/spec/requests/solid_queue_web/failed_job_selections_spec.rb b/spec/requests/solid_queue_web/failed_job_selections_spec.rb new file mode 100644 index 0000000..fadd799 --- /dev/null +++ b/spec/requests/solid_queue_web/failed_job_selections_spec.rb @@ -0,0 +1,112 @@ +require "rails_helper" + +RSpec.describe "Failed Job Selections", type: :request do + let!(:job) do + SolidQueue::Job.create!( + queue_name: "default", + class_name: "TestJob", + arguments: {}, + active_job_id: SecureRandom.uuid + ) + end + + let!(:other_job) do + SolidQueue::Job.create!( + queue_name: "default", + class_name: "OtherJob", + arguments: {}, + active_job_id: SecureRandom.uuid + ) + end + + let!(:execution) do + job.ready_execution&.destroy + SolidQueue::FailedExecution.create!( + job: job, + error: { exception_class: "RuntimeError", message: "boom", backtrace: [] } + ) + end + + let!(:other_execution) do + other_job.ready_execution&.destroy + SolidQueue::FailedExecution.create!( + job: other_job, + error: { exception_class: "RuntimeError", message: "bang", backtrace: [] } + ) + end + + describe "POST /jobs/failed_jobs/selection (retry selected)" do + it "retries only the selected jobs" do + post "/jobs/failed_jobs/selection", params: { ids: [ execution.id ] } + expect(SolidQueue::FailedExecution.where(id: execution.id)).to be_empty + expect(SolidQueue::FailedExecution.where(id: other_execution.id)).to exist + end + + it "redirects to the failed jobs list" do + post "/jobs/failed_jobs/selection", params: { ids: [ execution.id ] } + expect(response).to redirect_to("/jobs/failed_jobs") + end + + it "shows a notice with the count of retried jobs" do + post "/jobs/failed_jobs/selection", params: { ids: [ execution.id ] } + follow_redirect! + expect(response.body).to include("retry") + end + + it "retries multiple selected jobs" do + post "/jobs/failed_jobs/selection", + params: { ids: [ execution.id, other_execution.id ] } + expect(SolidQueue::FailedExecution.count).to eq(0) + end + + it "handles unexpected errors gracefully" do + allow(SolidQueue::FailedExecution).to receive(:retry_all).and_raise(RuntimeError, "db error") + post "/jobs/failed_jobs/selection", params: { ids: [ execution.id ] } + expect(response).to redirect_to("/jobs/failed_jobs") + follow_redirect! + expect(response.body).to include("Could not retry jobs") + end + end + + describe "DELETE /jobs/failed_jobs/selection (discard selected)" do + it "discards only the selected jobs" do + delete "/jobs/failed_jobs/selection", params: { ids: [ execution.id ] } + expect(SolidQueue::FailedExecution.where(id: execution.id)).to be_empty + expect(SolidQueue::FailedExecution.where(id: other_execution.id)).to exist + end + + it "redirects to the failed jobs list" do + delete "/jobs/failed_jobs/selection", params: { ids: [ execution.id ] } + expect(response).to redirect_to("/jobs/failed_jobs") + end + + it "shows a notice with the count of discarded jobs" do + delete "/jobs/failed_jobs/selection", params: { ids: [ execution.id ] } + follow_redirect! + expect(response.body).to include("discarded") + end + + it "discards multiple selected jobs" do + delete "/jobs/failed_jobs/selection", + params: { ids: [ execution.id, other_execution.id ] } + expect(SolidQueue::FailedExecution.count).to eq(0) + end + + it "handles unexpected errors gracefully" do + allow(SolidQueue::FailedExecution).to receive(:discard_all_from_jobs).and_raise(RuntimeError, "db error") + delete "/jobs/failed_jobs/selection", params: { ids: [ execution.id ] } + expect(response).to redirect_to("/jobs/failed_jobs") + follow_redirect! + expect(response.body).to include("Could not discard jobs") + end + end + + describe "GET /jobs/failed_jobs with selection UI" do + it "renders checkboxes when failed jobs exist" do + get "/jobs/failed_jobs" + expect(response.body).to include('type="checkbox"') + expect(response.body).to include("retry-selection-form") + expect(response.body).to include("discard-selection-form") + end + end +end diff --git a/spec/requests/solid_queue_web/job_selections_spec.rb b/spec/requests/solid_queue_web/job_selections_spec.rb new file mode 100644 index 0000000..4515103 --- /dev/null +++ b/spec/requests/solid_queue_web/job_selections_spec.rb @@ -0,0 +1,86 @@ +require "rails_helper" + +RSpec.describe "Job Selections", type: :request do + let!(:ready_job) do + SolidQueue::Job.create!( + queue_name: "default", + class_name: "TestJob", + arguments: {}, + active_job_id: SecureRandom.uuid + ) + end + + let!(:other_job) do + SolidQueue::Job.create!( + queue_name: "default", + class_name: "OtherJob", + arguments: {}, + active_job_id: SecureRandom.uuid + ) + end + + let(:ready_execution) { ready_job.ready_execution } + let(:other_execution) { other_job.ready_execution } + + describe "DELETE /jobs/list/selection (discard selected)" do + it "discards only the selected executions" do + delete "/jobs/list/selection", + params: { status: "ready", ids: [ ready_execution.id ] } + expect(SolidQueue::ReadyExecution.where(id: ready_execution.id)).to be_empty + expect(SolidQueue::ReadyExecution.where(id: other_execution.id)).to exist + end + + it "redirects to the jobs list preserving status" do + delete "/jobs/list/selection", + params: { status: "ready", ids: [ ready_execution.id ] } + expect(response).to redirect_to(/status=ready/) + end + + it "preserves the period param in the redirect" do + delete "/jobs/list/selection", + params: { status: "ready", period: "1h", ids: [ ready_execution.id ] } + expect(response).to redirect_to(/period=1h/) + end + + it "discards multiple selected executions" do + delete "/jobs/list/selection", + params: { status: "ready", ids: [ ready_execution.id, other_execution.id ] } + expect(SolidQueue::ReadyExecution.count).to eq(0) + end + + it "shows a notice with the count of discarded jobs" do + delete "/jobs/list/selection", + params: { status: "ready", ids: [ ready_execution.id ] } + follow_redirect! + expect(response.body).to include("discarded") + end + + it "rejects non-discardable statuses" do + delete "/jobs/list/selection", + params: { status: "claimed", ids: [ ready_execution.id ] } + expect(response).to redirect_to(/status=claimed/) + end + + it "handles unexpected errors gracefully" do + allow(SolidQueue::ReadyExecution).to receive(:discard_all_from_jobs).and_raise(RuntimeError, "db error") + delete "/jobs/list/selection", + params: { status: "ready", ids: [ ready_execution.id ] } + expect(response).to redirect_to(/status=ready/) + follow_redirect! + expect(response.body).to include("Could not discard jobs") + end + end + + describe "GET /jobs/list with selection UI" do + it "renders checkboxes for discardable statuses" do + get "/jobs/list", params: { status: "ready" } + expect(response.body).to include('type="checkbox"') + expect(response.body).to include("selection-form") + end + + it "does not render checkboxes for non-discardable statuses" do + get "/jobs/list", params: { status: "claimed" } + expect(response.body).not_to include("selection-form") + end + end +end