From f1acb8086b30d58951113326562149f366301809 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Mon, 25 May 2026 21:16:30 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20per-queue=20job=20browser=20?= =?UTF-8?q?=E2=80=94=20drill=20into=20any=20queue=20to=20see=20and=20disca?= =?UTF-8?q?rd=20its=20ready=20jobs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + README.md | 2 +- ROADMAP.md | 1 - .../solid_stack_web/_02_layout.css | 1 + .../solid_stack_web/queues_controller.rb | 11 +++ .../solid_stack_web/queues/index.html.erb | 4 +- .../solid_stack_web/queues/show.html.erb | 67 +++++++++++++++++++ config/routes.rb | 2 +- spec/requests/solid_stack_web/queues_spec.rb | 47 +++++++++++++ 9 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 app/views/solid_stack_web/queues/show.html.erb diff --git a/CHANGELOG.md b/CHANGELOG.md index fc0aa1c..28e72c6 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 +- Per-queue job browser — queue names and sizes on the Queues index are now links to `GET /queues/:id`, which shows a paginated list of ready jobs for that queue with job class, priority, and enqueued-at; individual "Discard" buttons remove a single job; a "Discard All Ready (N)" header button discards every ready job in the queue in one request; pause/resume controls are present on the show page so operators never need to leave the queue context - Recurring task list — `GET /recurring_tasks` enumerates all `SolidQueue::RecurringTask` records with key, cron schedule, job class or command, queue, next-run time, last-run time, and static/dynamic badge; each row has a "Run Now" button that immediately enqueues the task via `RecurringTasks::RunsController`; "Recurring" link added to the queue subnav - Job history view — `GET /history` lists all finished jobs with class name, queue, duration, and finished-at time; filterable by queue, class substring, and time period (1h / 24h / 7d); clicking a queue badge filters the history to that queue; CSV export respects active filters; "History" link added to the queue subnav - Scheduled job management — "Run Now" and offset buttons (+1h / +24h / +7d) on each scheduled job row; Turbo Stream removes the row on run-now and updates the scheduled-at cell on offset reschedule; "Run All Now (N)" header button back-dates all matching scheduled executions; backed by `ScheduledJobsController` using standard CRUD (`update` for single, `create` for bulk via `run_all_now` collection route) diff --git a/README.md b/README.md index 94c753a..7e5e8fc 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 / bulk retry / bulk discard), pause/resume queues, and inspect worker processes; **Bulk selection** checkbox-selects individual jobs for discard or retry; **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 / bulk retry / bulk discard), pause/resume queues, and inspect worker processes; **Bulk selection** checkbox-selects individual jobs for discard or retry; **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; **Per-queue browser** — click any queue name or size to drill into its ready jobs with per-row and bulk discard - **Job history view** — paginated list of all finished jobs with class name, queue, duration, and finished-at time; filterable by queue (click a badge), class substring, and time period; CSV export respects active filters - **Scheduled job management** — "Run Now" and offset buttons (+1h / +24h / +7d) per row update the scheduled time inline via Turbo Stream; "Run All Now (N)" in the header back-dates all matching executions at once - **Recurring task list** — enumerates all `SolidQueue::RecurringTask` records with cron schedule, job class or command, queue, next-run and last-run times, and a static/dynamic badge; each row has a "Run Now" button that immediately enqueues the task diff --git a/ROADMAP.md b/ROADMAP.md index f3f64e5..1f5874a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,7 +11,6 @@ The path to v1.0.0 is staged: first achieve feature parity with `solid_queue_das > _Close the remaining Solid Queue feature gaps._ ### Added -- **Per-queue job browser** — drill into any queue from the Queues list to see its ready jobs and discard them - **Blocked job bulk discard** — "Discard all blocked" action on the blocked jobs view --- diff --git a/app/assets/stylesheets/solid_stack_web/_02_layout.css b/app/assets/stylesheets/solid_stack_web/_02_layout.css index e206515..9971954 100644 --- a/app/assets/stylesheets/solid_stack_web/_02_layout.css +++ b/app/assets/stylesheets/solid_stack_web/_02_layout.css @@ -72,6 +72,7 @@ .sqw-page-header { margin-bottom: 1.25rem; } .sqw-page-title { font-size: 20px; font-weight: 600; } +.sqw-page-title-row { display: flex; align-items: center; gap: 0.5rem; } @keyframes sqw-flash-dismiss { 0%, 86% { opacity: 1; max-height: 200px; margin-bottom: 1rem; } diff --git a/app/controllers/solid_stack_web/queues_controller.rb b/app/controllers/solid_stack_web/queues_controller.rb index 4f2a582..e6e6c8f 100644 --- a/app/controllers/solid_stack_web/queues_controller.rb +++ b/app/controllers/solid_stack_web/queues_controller.rb @@ -13,6 +13,17 @@ def index end end + def show + @queue_name = params[:id] + @paused = ::SolidQueue::Pause.exists?(queue_name: @queue_name) + @pagy, @executions = pagy( + ::SolidQueue::ReadyExecution + .where(queue_name: @queue_name) + .includes(:job) + .order(created_at: :desc) + ) + end + def pause ::SolidQueue::Pause.find_or_create_by!(queue_name: params[:id]) redirect_to queues_path diff --git a/app/views/solid_stack_web/queues/index.html.erb b/app/views/solid_stack_web/queues/index.html.erb index ca239f9..8f7bef6 100644 --- a/app/views/solid_stack_web/queues/index.html.erb +++ b/app/views/solid_stack_web/queues/index.html.erb @@ -15,8 +15,8 @@ <% @queues.each do |queue| %> - <%= queue[:name] %> - <%= queue[:size] %> + <%= link_to queue[:name], queue_path(queue[:name]) %> + <%= link_to queue[:size], queue_path(queue[:name]) %> <% if queue[:paused] %> Paused diff --git a/app/views/solid_stack_web/queues/show.html.erb b/app/views/solid_stack_web/queues/show.html.erb new file mode 100644 index 0000000..661abb8 --- /dev/null +++ b/app/views/solid_stack_web/queues/show.html.erb @@ -0,0 +1,67 @@ +
+
+
+ <%= link_to "Queues", queues_path %> › <%= @queue_name %> +
+
+

<%= @queue_name %>

+ <% if @paused %> + Paused + <% else %> + Running + <% end %> +
+
+
+ <% if @paused %> + <%= button_to "Resume", resume_queue_path(@queue_name), + method: :delete, class: "sqw-btn sqw-btn--sm" %> + <% else %> + <%= button_to "Pause", pause_queue_path(@queue_name), + method: :post, class: "sqw-btn sqw-btn--sm" %> + <% end %> + <% if @executions.any? %> + <%= button_to "Discard All Ready (#{@pagy.count})", + discard_all_jobs_path(status: "ready", queue: @queue_name), + method: :post, + class: "sqw-btn sqw-btn--danger sqw-btn--sm", + data: { turbo_confirm: "Discard all #{@pagy.count} ready jobs in #{@queue_name}? This cannot be undone.", + turbo_frame: "_top" } %> + <% end %> +
+
+ +<% if @executions.any? %> + + + + + + + + + + + <% @executions.each do |execution| %> + + + + + + + <% end %> + +
Job ClassPriorityEnqueued At
+ <%= link_to execution.job.class_name, job_path(execution.id, status: "ready"), + data: { turbo_frame: "_top" } %> + <%= execution.job.priority %><%= execution.created_at.strftime("%b %d %H:%M") %> + <%= button_to "Discard", job_path(execution, status: "ready", queue: @queue_name), + method: :delete, class: "sqw-btn sqw-btn--danger sqw-btn--sm", + data: { turbo_confirm: "Discard this job?" } %> +
+ <%== pagy_nav(@pagy) if @pagy.pages > 1 %> +<% else %> +
+

No ready jobs in <%= @queue_name %>.

+
+<% end %> \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 94d3054..3dd232f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -25,7 +25,7 @@ resource :arguments, only: [:update], controller: "failed_jobs/arguments" end - resources :queues, only: [:index] do + resources :queues, only: [:index, :show] do member do post :pause delete :resume diff --git a/spec/requests/solid_stack_web/queues_spec.rb b/spec/requests/solid_stack_web/queues_spec.rb index 0f797d6..e003576 100644 --- a/spec/requests/solid_stack_web/queues_spec.rb +++ b/spec/requests/solid_stack_web/queues_spec.rb @@ -50,6 +50,53 @@ def create_ready(queue_name: "default") end end + describe "GET /queues/:id" do + it "returns 200 and lists ready jobs for the queue" do + create_ready(queue_name: "urgent") + get "#{engine_root}/queues/urgent" + expect(response).to have_http_status(:ok) + expect(response.body).to include("urgent") + expect(response.body).to include("MyJob") + end + + it "shows the running badge when the queue is not paused" do + create_ready(queue_name: "urgent") + get "#{engine_root}/queues/urgent" + expect(response.body).to include("Running") + end + + it "shows the paused badge when the queue is paused" do + create_ready(queue_name: "urgent") + SolidQueue::Pause.create!(queue_name: "urgent") + get "#{engine_root}/queues/urgent" + expect(response.body).to include("Paused") + end + + it "shows an empty state when the queue has no ready jobs" do + get "#{engine_root}/queues/ghost" + expect(response).to have_http_status(:ok) + expect(response.body).to include("No ready jobs") + end + + it "does not show jobs from other queues" do + create_ready(queue_name: "alpha") + create_ready(queue_name: "beta") + get "#{engine_root}/queues/alpha" + expect(response.body).not_to include("beta") + end + + it "includes a breadcrumb link back to queues" do + get "#{engine_root}/queues/urgent" + expect(response.body).to include("#{engine_root}/queues") + end + + it "includes a discard all button when jobs exist" do + create_ready(queue_name: "urgent") + get "#{engine_root}/queues/urgent" + expect(response.body).to include("Discard All Ready") + end + end + describe "DELETE /queues/:id/resume" do it "resumes a paused queue and redirects" do SolidQueue::Pause.create!(queue_name: "default") From 78cdafa8278d26947a1c6b8c09bd03ac61a8cdfe Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Mon, 25 May 2026 21:20:42 -0400 Subject: [PATCH 2/3] refactor: replace non-standard pause/resume actions with Queues::PausesController#create/destroy Co-Authored-By: Claude Sonnet 4.6 --- .../solid_stack_web/queues/pauses_controller.rb | 13 +++++++++++++ .../solid_stack_web/queues_controller.rb | 10 ---------- app/views/solid_stack_web/queues/index.html.erb | 4 ++-- app/views/solid_stack_web/queues/show.html.erb | 4 ++-- config/routes.rb | 5 +---- spec/requests/solid_stack_web/queues_spec.rb | 8 ++++---- 6 files changed, 22 insertions(+), 22 deletions(-) create mode 100644 app/controllers/solid_stack_web/queues/pauses_controller.rb diff --git a/app/controllers/solid_stack_web/queues/pauses_controller.rb b/app/controllers/solid_stack_web/queues/pauses_controller.rb new file mode 100644 index 0000000..67d3028 --- /dev/null +++ b/app/controllers/solid_stack_web/queues/pauses_controller.rb @@ -0,0 +1,13 @@ +module SolidStackWeb + class Queues::PausesController < ApplicationController + def create + ::SolidQueue::Pause.find_or_create_by!(queue_name: params[:queue_id]) + redirect_back_or_to queues_path + end + + def destroy + ::SolidQueue::Pause.find_by(queue_name: params[:queue_id])&.destroy + redirect_back_or_to queues_path + end + end +end diff --git a/app/controllers/solid_stack_web/queues_controller.rb b/app/controllers/solid_stack_web/queues_controller.rb index e6e6c8f..4721cff 100644 --- a/app/controllers/solid_stack_web/queues_controller.rb +++ b/app/controllers/solid_stack_web/queues_controller.rb @@ -23,15 +23,5 @@ def show .order(created_at: :desc) ) end - - def pause - ::SolidQueue::Pause.find_or_create_by!(queue_name: params[:id]) - redirect_to queues_path - end - - def resume - ::SolidQueue::Pause.find_by(queue_name: params[:id])&.destroy - redirect_to queues_path - end end end diff --git a/app/views/solid_stack_web/queues/index.html.erb b/app/views/solid_stack_web/queues/index.html.erb index 8f7bef6..1b8b403 100644 --- a/app/views/solid_stack_web/queues/index.html.erb +++ b/app/views/solid_stack_web/queues/index.html.erb @@ -26,10 +26,10 @@ <% if queue[:paused] %> - <%= button_to "Resume", resume_queue_path(queue[:name]), + <%= button_to "Resume", queue_pause_path(queue[:name]), method: :delete, class: "sqw-btn sqw-btn--sm" %> <% else %> - <%= button_to "Pause", pause_queue_path(queue[:name]), + <%= button_to "Pause", queue_pause_path(queue[:name]), method: :post, class: "sqw-btn sqw-btn--sm" %> <% end %> diff --git a/app/views/solid_stack_web/queues/show.html.erb b/app/views/solid_stack_web/queues/show.html.erb index 661abb8..0e2ffc8 100644 --- a/app/views/solid_stack_web/queues/show.html.erb +++ b/app/views/solid_stack_web/queues/show.html.erb @@ -14,10 +14,10 @@
<% if @paused %> - <%= button_to "Resume", resume_queue_path(@queue_name), + <%= button_to "Resume", queue_pause_path(@queue_name), method: :delete, class: "sqw-btn sqw-btn--sm" %> <% else %> - <%= button_to "Pause", pause_queue_path(@queue_name), + <%= button_to "Pause", queue_pause_path(@queue_name), method: :post, class: "sqw-btn sqw-btn--sm" %> <% end %> <% if @executions.any? %> diff --git a/config/routes.rb b/config/routes.rb index 3dd232f..aef6aba 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -26,10 +26,7 @@ end resources :queues, only: [:index, :show] do - member do - post :pause - delete :resume - end + resource :pause, only: [:create, :destroy], controller: "queues/pauses" end resources :processes, only: [:index] diff --git a/spec/requests/solid_stack_web/queues_spec.rb b/spec/requests/solid_stack_web/queues_spec.rb index e003576..4696cbe 100644 --- a/spec/requests/solid_stack_web/queues_spec.rb +++ b/spec/requests/solid_stack_web/queues_spec.rb @@ -35,7 +35,7 @@ def create_ready(queue_name: "default") end end - describe "POST /queues/:id/pause" do + describe "POST /queues/:queue_id/pause" do it "pauses the queue and redirects" do create_ready(queue_name: "default") post "#{engine_root}/queues/default/pause" @@ -97,16 +97,16 @@ def create_ready(queue_name: "default") end end - describe "DELETE /queues/:id/resume" do + describe "DELETE /queues/:queue_id/pause" do it "resumes a paused queue and redirects" do SolidQueue::Pause.create!(queue_name: "default") - delete "#{engine_root}/queues/default/resume" + delete "#{engine_root}/queues/default/pause" expect(response).to redirect_to("#{engine_root}/queues") expect(SolidQueue::Pause.exists?(queue_name: "default")).to be false end it "is a no-op when the queue is not paused" do - expect { delete "#{engine_root}/queues/default/resume" }.not_to raise_error + expect { delete "#{engine_root}/queues/default/pause" }.not_to raise_error expect(response).to redirect_to("#{engine_root}/queues") end end From ff68291900f6755b7d460bc6939bf82a8bbc05a5 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Mon, 25 May 2026 21:22:04 -0400 Subject: [PATCH 3/3] refactor: extract set_ids before_action in FailedJobs::SelectionsController Co-Authored-By: Claude Sonnet 4.6 --- .../failed_jobs/selections_controller.rb | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/controllers/solid_stack_web/failed_jobs/selections_controller.rb b/app/controllers/solid_stack_web/failed_jobs/selections_controller.rb index 006de67..9d8e8a3 100644 --- a/app/controllers/solid_stack_web/failed_jobs/selections_controller.rb +++ b/app/controllers/solid_stack_web/failed_jobs/selections_controller.rb @@ -1,22 +1,28 @@ module SolidStackWeb module FailedJobs class SelectionsController < ApplicationController + before_action :set_ids + def create - ids = Array(params[:job_ids]).map(&:to_i).reject(&:zero?) - SolidQueue::FailedExecution.where(id: ids).each(&:retry) + SolidQueue::FailedExecution.where(id: @ids).each(&:retry) redirect_to failed_jobs_path rescue => e redirect_to failed_jobs_path, alert: "Could not retry jobs: #{e.message}" end def destroy - ids = Array(params[:job_ids]).map(&:to_i).reject(&:zero?) - job_ids = SolidQueue::FailedExecution.where(id: ids).pluck(:job_id) + job_ids = SolidQueue::FailedExecution.where(id: @ids).pluck(:job_id) SolidQueue::Job.where(id: job_ids).destroy_all redirect_to failed_jobs_path rescue => e redirect_to failed_jobs_path, alert: "Could not discard jobs: #{e.message}" end + + private + + def set_ids + @ids = Array(params[:job_ids]).map(&:to_i).reject(&:zero?) + end end end end