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 @@
<% if @executions.any? %> - - - - - - - - <% if @status == "scheduled" %><% end %> - - - - - <% @executions.each do |execution| %> - - - - - - <% if @status == "scheduled" %> - + <%= form_with url: job_selection_path, + method: :delete, + data: { turbo_frame: "_top" } do |f| %> + <%= f.hidden_field :status, value: @status %> + <%= f.hidden_field :q, value: @search %> + <%= f.hidden_field :queue, value: @queue %> + <%= f.hidden_field :period, value: @period %> + <%= f.hidden_field :priority, value: @priority %> + + <% if SolidStackWeb::Job::DISCARDABLE.include?(@status) %> +
+ <%= f.submit "Discard Selected", + class: "sqw-btn sqw-btn--danger sqw-btn--sm", + data: { turbo_confirm: "Discard selected jobs? This cannot be undone." } %> +
+ <% end %> + +
Job ClassQueuePriorityEnqueued AtScheduled At
<%= link_to execution.job.class_name, job_path(execution.id, status: @status), data: { turbo_frame: "_top" } %><%= execution.job.queue_name %><%= execution.job.priority %><%= execution.created_at.strftime("%b %d %H:%M") %><%= execution.scheduled_at&.strftime("%b %d %H:%M") %>
+ + + <% if SolidStackWeb::Job::DISCARDABLE.include?(@status) %> + <% end %> - + + + + + <% if @status == "scheduled" %><% end %> + - <% end %> - -
- <% if %w[ready scheduled blocked].include?(@status) %> - <%= button_to "Discard", job_path(execution, status: @status, q: @search, queue: @queue, period: @period, priority: @priority), - method: :delete, class: "sqw-btn sqw-btn--danger sqw-btn--sm", - data: { turbo_confirm: "Discard this job?" } %> - <% end %> - Job ClassQueuePriorityEnqueued AtScheduled At
- <%== pagy_nav(@pagy) if @pagy.pages > 1 %> + + + <% @executions.each do |execution| %> + + <% if SolidStackWeb::Job::DISCARDABLE.include?(@status) %> + + <% end %> + <%= link_to execution.job.class_name, job_path(execution.id, status: @status), data: { turbo_frame: "_top" } %> + <%= execution.job.queue_name %> + <%= execution.job.priority %> + <%= execution.created_at.strftime("%b %d %H:%M") %> + <% if @status == "scheduled" %> + <%= execution.scheduled_at&.strftime("%b %d %H:%M") %> + <% end %> + + <% if %w[ready scheduled blocked].include?(@status) %> + <%= button_to "Discard", job_path(execution, status: @status, q: @search, queue: @queue, period: @period, priority: @priority), + method: :delete, class: "sqw-btn sqw-btn--danger sqw-btn--sm", + data: { turbo_confirm: "Discard this job?" } %> + <% end %> + + + <% end %> + + + <%== 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('').length).to eq(1) end end