From 527ea9faba75341a4009f7c11afc49fbafc5abba Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Wed, 20 May 2026 09:23:56 -0400 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20dashboard=20quick=20actions=20?= =?UTF-8?q?=E2=80=94=20Retry=20All=20Failed=20and=20Discard=20All=20Blocke?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two one-click bulk actions directly on the dashboard: - "Retry All Failed" — queues all failed executions for retry - "Discard All Blocked" — permanently removes all blocked executions Each action renders as a card (Failed Jobs / Blocked Jobs) only when the respective count is non-zero, keeping the dashboard clean when everything is healthy. Both include a confirm dialog and redirect back to the dashboard with a notice or alert on error. Co-Authored-By: Claude Sonnet 4.6 --- .../solid_queue_web/dashboard_controller.rb | 16 +++++ .../solid_queue_web/dashboard/index.html.erb | 32 +++++++-- config/routes.rb | 2 + .../solid_queue_web/dashboard_spec.rb | 68 +++++++++++++++++++ 4 files changed, 113 insertions(+), 5 deletions(-) diff --git a/app/controllers/solid_queue_web/dashboard_controller.rb b/app/controllers/solid_queue_web/dashboard_controller.rb index 3a7ba5c..dc6753e 100644 --- a/app/controllers/solid_queue_web/dashboard_controller.rb +++ b/app/controllers/solid_queue_web/dashboard_controller.rb @@ -24,5 +24,21 @@ def index finished_times.count { |t| t >= from && t < to } end end + + def retry_all_failed + jobs = SolidQueue::FailedExecution.includes(:job).map(&:job) + SolidQueue::FailedExecution.retry_all(jobs) + redirect_to root_path, notice: "#{jobs.size} failed #{"job".pluralize(jobs.size)} queued for retry." + rescue => e + redirect_to root_path, alert: "Could not retry failed jobs: #{e.message}" + end + + def discard_all_blocked + jobs = SolidQueue::BlockedExecution.includes(:job).map(&:job) + SolidQueue::BlockedExecution.discard_all_from_jobs(jobs) + redirect_to root_path, notice: "#{jobs.size} blocked #{"job".pluralize(jobs.size)} discarded." + rescue => e + redirect_to root_path, alert: "Could not discard blocked jobs: #{e.message}" + end end end diff --git a/app/views/solid_queue_web/dashboard/index.html.erb b/app/views/solid_queue_web/dashboard/index.html.erb index 44ff981..f5d9942 100644 --- a/app/views/solid_queue_web/dashboard/index.html.erb +++ b/app/views/solid_queue_web/dashboard/index.html.erb @@ -74,7 +74,7 @@ <% end %> -
+
Quick Links @@ -91,13 +91,35 @@ <% if @stats[:failed] > 0 %>
- Attention Required + Failed Jobs
-
-

+

+

<%= pluralize(@stats[:failed], "failed job") %> need attention.

- <%= link_to "Review failed jobs →", failed_jobs_path, class: "sqd-btn sqd-btn--danger" %> + <%= button_to "Retry All Failed", retry_all_failed_path, + method: :post, + class: "sqd-btn sqd-btn--primary", + data: { confirm: "Retry all #{@stats[:failed]} failed #{"job".pluralize(@stats[:failed])}?" } %> + <%= link_to "Review →", failed_jobs_path, class: "sqd-btn sqd-btn--muted" %> +
+
+ <% end %> + + <% if @stats[:blocked] > 0 %> +
+
+ Blocked Jobs +
+
+

+ <%= pluralize(@stats[:blocked], "blocked job") %>. +

+ <%= button_to "Discard All Blocked", discard_all_blocked_path, + method: :post, + class: "sqd-btn sqd-btn--danger", + data: { confirm: "Discard all #{@stats[:blocked]} blocked #{"job".pluralize(@stats[:blocked])}? This cannot be undone." } %> + <%= link_to "Review →", jobs_path(status: "blocked"), class: "sqd-btn sqd-btn--muted" %>
<% end %> diff --git a/config/routes.rb b/config/routes.rb index 28aae38..e389559 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,7 @@ SolidQueueWeb::Engine.routes.draw do root to: "dashboard#index" + post "retry_all_failed", to: "dashboard#retry_all_failed", as: :retry_all_failed + post "discard_all_blocked", to: "dashboard#discard_all_blocked", as: :discard_all_blocked get "search", to: "search#index", as: :search get "history", to: "history#index", as: :history diff --git a/spec/requests/solid_queue_web/dashboard_spec.rb b/spec/requests/solid_queue_web/dashboard_spec.rb index 088ad0d..87a3d77 100644 --- a/spec/requests/solid_queue_web/dashboard_spec.rb +++ b/spec/requests/solid_queue_web/dashboard_spec.rb @@ -46,6 +46,74 @@ end end + describe "POST /jobs/retry_all_failed" do + let!(:failed_job) do + j = SolidQueue::Job.create!( + queue_name: "default", class_name: "TestJob", + arguments: {}.to_json, active_job_id: SecureRandom.uuid + ) + SolidQueue::FailedExecution.create!( + job: j, + error: { exception_class: "RuntimeError", message: "boom", backtrace: [] } + ) + j + end + + it "retries all failed jobs and redirects to the dashboard" do + post "/jobs/retry_all_failed" + expect(response).to redirect_to("/jobs/") + follow_redirect! + expect(response.body).to include("queued for retry") + end + + it "removes the failed execution" do + expect { post "/jobs/retry_all_failed" }.to change(SolidQueue::FailedExecution, :count).by(-1) + end + + it "handles failure gracefully" do + allow(SolidQueue::FailedExecution).to receive(:retry_all).and_raise(RuntimeError, "boom") + post "/jobs/retry_all_failed" + expect(response).to redirect_to("/jobs/") + follow_redirect! + expect(response.body).to include("Could not retry failed jobs") + end + end + + describe "POST /jobs/discard_all_blocked" do + let!(:blocked_job) do + j = SolidQueue::Job.create!( + queue_name: "default", class_name: "TestJob", + arguments: {}.to_json, active_job_id: SecureRandom.uuid + ) + j.ready_execution&.destroy + j.update!(concurrency_key: "TestJob/1") + allow_any_instance_of(SolidQueue::BlockedExecution).to receive(:set_expires_at) + SolidQueue::BlockedExecution.create!( + job: j, queue_name: j.queue_name, priority: j.priority, expires_at: 1.hour.from_now + ) + j + end + + it "discards all blocked jobs and redirects to the dashboard" do + post "/jobs/discard_all_blocked" + expect(response).to redirect_to("/jobs/") + follow_redirect! + expect(response.body).to include("discarded") + end + + it "removes the blocked execution" do + expect { post "/jobs/discard_all_blocked" }.to change(SolidQueue::BlockedExecution, :count).by(-1) + end + + it "handles failure gracefully" do + allow(SolidQueue::BlockedExecution).to receive(:discard_all_from_jobs).and_raise(RuntimeError, "boom") + post "/jobs/discard_all_blocked" + expect(response).to redirect_to("/jobs/") + follow_redirect! + expect(response.body).to include("Could not discard blocked jobs") + end + end + describe "authentication" do after { SolidQueueWeb.instance_variable_set(:@authenticate, nil) } From 28cc24291743d5a190817ff657b68fafa33acb2a Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Wed, 20 May 2026 09:24:51 -0400 Subject: [PATCH 2/4] docs: update CHANGELOG and README for dashboard quick actions Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + README.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abd8bfc..bceac14 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 +- Dashboard quick actions — "Retry All Failed" and "Discard All Blocked" buttons appear as cards on the dashboard when the respective count is non-zero; each includes a confirm dialog and redirects back to the dashboard with a count notice; cards are hidden when everything is healthy - Dark mode — a ☽/☀ toggle button in the header switches between light and dark themes; preference persists to `localStorage` and falls back to the OS `prefers-color-scheme` on first visit; implemented via a `[data-theme="dark"]` attribute on `` so all CSS custom properties inherit the new palette automatically; badge and flash hardcoded hex colors get explicit dark-mode overrides in a new `_12_dark_mode.css` partial; powered by a new Stimulus `theme_controller` - Configurable settings via `SolidQueueWeb.configure` — `page_size` (Pagy limit, default 25), `dashboard_refresh_interval` (ms, default 5 000), `default_refresh_interval` (ms, default 10 000 — applies to jobs, processes, and history pages), `search_results_limit` (max results per status in global search, default 25); all settings have safe defaults so zero host-app configuration is required diff --git a/README.md b/README.md index 160da32..d78e0e5 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ SolidQueueWeb surfaces all of this in a browser UI available at any route you ch - **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 - **Job history** — browsable list of all finished jobs with class name, queue, duration, and finished timestamp; filterable by period (1h / 24h / 7d), queue, and class name search; Done (1h) / Done (24h) dashboard cards link directly to the filtered history view; auto-refreshes every 10 seconds - **Dark mode** — ☽/☀ toggle in the header; preference persists to `localStorage` and defaults to the OS `prefers-color-scheme` on first visit; zero extra dependencies — implemented via CSS custom properties and a small Stimulus controller +- **Dashboard quick actions** — "Retry All Failed" and "Discard All Blocked" cards appear on the dashboard only when the respective count is non-zero; one-click bulk operations with confirm dialogs, keeping the dashboard clean when everything is healthy ## Screenshots @@ -116,7 +117,6 @@ end Planned features, roughly ordered by priority: **Medium-term** -- Dashboard quick actions — Retry All Failed / Clear All Blocked directly from the dashboard **Larger scope** - CSV export of any filtered view (jobs, failed jobs, history) From 0db881ae887e3ea280aa3fe856a5542ee82df5d5 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Wed, 20 May 2026 09:28:13 -0400 Subject: [PATCH 3/4] chore: seed blocked jobs for dashboard quick actions testing Co-Authored-By: Claude Sonnet 4.6 --- spec/dummy/db/seeds.rb | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/spec/dummy/db/seeds.rb b/spec/dummy/db/seeds.rb index e242d75..00a5d49 100644 --- a/spec/dummy/db/seeds.rb +++ b/spec/dummy/db/seeds.rb @@ -106,6 +106,34 @@ ) end +puts "Seeding blocked jobs..." +# Skip set_expires_at callback which requires a real ActiveJob class to exist +SolidQueue::BlockedExecution.skip_callback(:create, :before, :set_expires_at) +begin + 5.times do |i| + job = SolidQueue::Job.create!( + queue_name: queues.sample, + class_name: job_classes.sample, + arguments: { item_id: i + 1 }.to_json, + priority: 0, + active_job_id: SecureRandom.uuid, + concurrency_key: "#{job_classes.sample}/#{i + 1}", + created_at: rand(1..12).hours.ago, + updated_at: rand(1..12).hours.ago + ) + job.ready_execution&.destroy + SolidQueue::BlockedExecution.create!( + job: job, + queue_name: job.queue_name, + priority: job.priority, + expires_at: rand(1..4).hours.from_now, + created_at: job.created_at + ) + end +ensure + SolidQueue::BlockedExecution.set_callback(:create, :before, :set_expires_at) +end + puts "Seeding finished jobs (for throughput chart)..." # Recent jobs — guaranteed within last 30 minutes so Done (1h) is non-zero 5.times do |i| @@ -147,6 +175,7 @@ puts " #{SolidQueue::ReadyExecution.count} ready jobs" puts " #{SolidQueue::ScheduledExecution.count} scheduled jobs" puts " #{SolidQueue::ClaimedExecution.count} claimed (running) jobs" +puts " #{SolidQueue::BlockedExecution.count} blocked jobs" puts " #{SolidQueue::FailedExecution.count} failed jobs" puts " #{SolidQueue::Process.count} processes" puts " #{SolidQueue::Job.where.not(finished_at: nil).count} finished jobs" From 56d1d1feeb62ff48028c74efc4f9cbeb26b657b1 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Wed, 20 May 2026 09:32:12 -0400 Subject: [PATCH 4/4] docs: merge Authentication and Configuration sections in README Co-Authored-By: Claude Sonnet 4.6 --- README.md | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index d78e0e5..6f6f566 100644 --- a/README.md +++ b/README.md @@ -83,25 +83,11 @@ Add to your `config/routes.rb`: mount SolidQueueWeb::Engine, at: "/jobs" ``` -The dashboard will be available at `/jobs`. See [Authentication](#authentication) to restrict access to admin users. - -## Authentication - -The engine ships with no authentication by default. Add a block to an initializer (e.g. `config/initializers/solid_queue_web.rb`) to protect the dashboard: - -```ruby -SolidQueueWeb.authenticate do - # Called in the context of ApplicationController — use any helper available there. - # Return a truthy value to allow access, falsy to deny (triggers HTTP Basic prompt). - current_user&.admin? -end -``` - -HTTP Basic authentication is used as a fallback when the block returns falsy. +The dashboard will be available at `/jobs`. ## Configuration -All settings are optional — the dashboard works with zero configuration. To override defaults, add a block to an initializer (e.g. `config/initializers/solid_queue_web.rb`): +All settings are optional — the dashboard works with zero configuration. Create `config/initializers/solid_queue_web.rb` to customize behavior: ```ruby SolidQueueWeb.configure do |config| @@ -110,14 +96,20 @@ SolidQueueWeb.configure do |config| config.default_refresh_interval = 30_000 # jobs/processes/history auto-refresh in ms (default: 10_000) config.search_results_limit = 10 # max results per status in global search (default: 25) end + +SolidQueueWeb.authenticate do + # Called in the context of ApplicationController — use any helper available there. + # Return a truthy value to allow access, falsy to deny (triggers HTTP Basic prompt). + current_user&.admin? +end ``` +No authentication is enforced by default. When the `authenticate` block returns falsy, HTTP Basic auth is used as a fallback. + ## Roadmap Planned features, roughly ordered by priority: -**Medium-term** - **Larger scope** - CSV export of any filtered view (jobs, failed jobs, history) - Webhook / alert config — POST to a URL when the failure count exceeds a threshold