diff --git a/CHANGELOG.md b/CHANGELOG.md index cba330c..7889f01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Job filtering — filter the jobs list by queue name, job class (substring), priority, and time period (1h / 24h / 7d / all) via query-param driven scopes; active filters are preserved across status tabs - Job filter Turbo Frame — filter form and results table wrapped in a `` so applying filters reloads only the table without a full page refresh; `data-turbo-action="advance"` keeps the URL in sync; Turbo JS loaded from esm.sh CDN in the engine layout +### Added + +- **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]` + ### Fixed - `FailedJobsController#destroy` used a local variable instead of `@execution`, making the Turbo Stream row-removal template a no-op diff --git a/README.md b/README.md index fa9cfb8..463fe47 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 +- **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 - **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 8abd422..c377912 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,7 +16,6 @@ The path to v1.0.0 is staged: first achieve feature parity with `solid_queue_das - **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 - **CSV export** — download jobs or failed jobs as CSV (class, queue, priority, enqueued_at, error) -- **Discard all** convenience action for ready/scheduled/blocked lists --- diff --git a/app/controllers/solid_stack_web/jobs_controller.rb b/app/controllers/solid_stack_web/jobs_controller.rb index 6c8ad1c..f21743f 100644 --- a/app/controllers/solid_stack_web/jobs_controller.rb +++ b/app/controllers/solid_stack_web/jobs_controller.rb @@ -1,7 +1,7 @@ module SolidStackWeb class JobsController < ApplicationController before_action :set_status - before_action :set_filters, only: :index + before_action :set_filters, only: [:index, :destroy] before_action :require_discardable, only: :destroy def index @@ -13,20 +13,25 @@ def index def show @execution = Job::EXECUTION_MODELS[@status].includes(:job).find(params[:id]) - @job = @execution.job - @arguments = JSON.parse(@job.arguments) if @job.arguments.present? + @arguments = JSON.parse(@execution.job.arguments) if @execution.job.arguments.present? rescue JSON::ParserError @arguments = nil end def destroy - @execution = Job::EXECUTION_MODELS[@status].find(params[:id]) - @execution.job.destroy! - @executions_remain = Job::EXECUTION_MODELS[@status].exists? + if params[:id] + @execution = Job::EXECUTION_MODELS[@status].find(params[:id]) + @execution.job.destroy! + @executions_remain = Job::EXECUTION_MODELS[@status].exists? - respond_to do |format| - format.html { redirect_to jobs_path(status: @status, q: params[:q], queue: params[:queue], period: params[:period], priority: params[:priority]) } - format.turbo_stream + respond_to do |format| + format.html { redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority) } + format.turbo_stream + end + else + job_ids = filtered_scope.pluck(:job_id) + SolidQueue::Job.where(id: job_ids).destroy_all + redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority) end end diff --git a/app/views/solid_stack_web/jobs/index.html.erb b/app/views/solid_stack_web/jobs/index.html.erb index 3795e9d..5c0843c 100644 --- a/app/views/solid_stack_web/jobs/index.html.erb +++ b/app/views/solid_stack_web/jobs/index.html.erb @@ -1,5 +1,13 @@ -
+

Jobs

+ <% if SolidStackWeb::Job::DISCARDABLE.include?(@status) && @executions&.any? %> + <%= button_to "Discard All (#{@pagy.count})", + discard_all_jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority), + method: :post, + class: "sqw-btn sqw-btn--danger sqw-btn--sm", + data: { turbo_confirm: "Discard all #{@pagy.count} jobs? This cannot be undone.", + turbo_frame: "_top" } %> + <% end %>
diff --git a/app/views/solid_stack_web/jobs/show.html.erb b/app/views/solid_stack_web/jobs/show.html.erb index 92f08dd..88852da 100644 --- a/app/views/solid_stack_web/jobs/show.html.erb +++ b/app/views/solid_stack_web/jobs/show.html.erb @@ -3,7 +3,7 @@
<%= link_to "Jobs", jobs_path(status: params[:status]) %> › Detail
-

<%= @job.class_name %>

+

<%= @execution.job.class_name %>

<% if SolidStackWeb::Job::DISCARDABLE.include?(@status) %> @@ -23,16 +23,16 @@
<%= SolidStackWeb::Job::TAB_LABELS[@status] %>
Queue
-
<%= @job.queue_name %>
+
<%= @execution.job.queue_name %>
Priority
-
<%= @job.priority %>
+
<%= @execution.job.priority %>
Active Job ID
-
<%= @job.active_job_id.presence || "—" %>
+
<%= @execution.job.active_job_id.presence || "—" %>
Concurrency Key
-
<%= @job.concurrency_key.presence || "—" %>
+
<%= @execution.job.concurrency_key.presence || "—" %>
<% if @status == "blocked" %>
Blocked Until
@@ -40,18 +40,18 @@ <% end %>
Enqueued At
-
<%= @job.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") %>
+
<%= @execution.job.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") %>
Scheduled At
-
<%= @job.scheduled_at ? @job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S UTC") : "—" %>
+
<%= @execution.job.scheduled_at ? @execution.job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S UTC") : "—" %>
Finished At
-
<%= @job.finished_at ? @job.finished_at.strftime("%Y-%m-%d %H:%M:%S UTC") : "—" %>
+
<%= @execution.job.finished_at ? @execution.job.finished_at.strftime("%Y-%m-%d %H:%M:%S UTC") : "—" %>

Arguments

-
<%= @arguments ? JSON.pretty_generate(@arguments) : (@job.arguments || "—") %>
+
<%= @arguments ? JSON.pretty_generate(@arguments) : (@execution.job.arguments || "—") %>
\ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 4b724db..5d308a3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,7 +1,9 @@ SolidStackWeb::Engine.routes.draw do root to: "dashboard#index" - resources :jobs, only: [:index, :show, :destroy] + resources :jobs, only: [:index, :show, :destroy] do + collection { post :discard_all, action: :destroy } + end resources :failed_jobs, only: [:index, :destroy] do member { post :retry } diff --git a/spec/requests/solid_stack_web/jobs_spec.rb b/spec/requests/solid_stack_web/jobs_spec.rb index f106d7a..8543b5f 100644 --- a/spec/requests/solid_stack_web/jobs_spec.rb +++ b/spec/requests/solid_stack_web/jobs_spec.rb @@ -260,6 +260,45 @@ def create_ready(class_name: "MyJob", queue_name: "default", priority: 0) end end + describe "POST /jobs/discard_all" do + it "destroys all jobs in the current status and redirects" do + create_ready(class_name: "JobA") + create_ready(class_name: "JobB") + + post "#{engine_root}/jobs/discard_all", params: { status: "ready" } + + expect(response).to redirect_to("#{engine_root}/jobs?status=ready") + expect(SolidQueue::ReadyExecution.count).to eq(0) + end + + it "only discards jobs matching active filters" do + create_ready(class_name: "ReportJob", queue_name: "reports") + create_ready(class_name: "CleanupJob", queue_name: "default") + + post "#{engine_root}/jobs/discard_all", params: { status: "ready", queue: "reports" } + + expect(SolidQueue::Job.where(class_name: "ReportJob").exists?).to be false + expect(SolidQueue::Job.where(class_name: "CleanupJob").exists?).to be true + end + + it "preserves filter params in the redirect" do + create_ready(queue_name: "reports") + + post "#{engine_root}/jobs/discard_all", + params: { status: "ready", queue: "reports", q: "Report", period: "1h" } + + expect(response.location).to include("queue=reports") + expect(response.location).to include("q=Report") + expect(response.location).to include("period=1h") + end + + it "returns 422 when status is not discardable" do + post "#{engine_root}/jobs/discard_all", params: { status: "claimed" } + + expect(response).to have_http_status(:unprocessable_content) + end + end + describe "combined filters" do it "applies class and queue filters together" do create_ready(class_name: "ReportJob", queue_name: "reports")