Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<html>` 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

Expand Down
30 changes: 11 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -82,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|
Expand All @@ -109,15 +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**
- 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)
- Webhook / alert config — POST to a URL when the failure count exceeds a threshold
Expand Down
16 changes: 16 additions & 0 deletions app/controllers/solid_queue_web/dashboard_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
32 changes: 27 additions & 5 deletions app/views/solid_queue_web/dashboard/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
<% end %>
</div>

<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem;">
<div class="sqd-card">
<div class="sqd-card__header">
<span class="sqd-card__title">Quick Links</span>
Expand All @@ -91,13 +91,35 @@
<% if @stats[:failed] > 0 %>
<div class="sqd-card">
<div class="sqd-card__header">
<span class="sqd-card__title">Attention Required</span>
<span class="sqd-card__title">Failed Jobs</span>
</div>
<div style="padding: 1rem;">
<p style="color: var(--danger); margin-bottom: 0.75rem;">
<div style="padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;">
<p style="color: var(--danger); font-size: 13px;">
<%= pluralize(@stats[:failed], "failed job") %> need attention.
</p>
<%= 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" %>
</div>
</div>
<% end %>

<% if @stats[:blocked] > 0 %>
<div class="sqd-card">
<div class="sqd-card__header">
<span class="sqd-card__title">Blocked Jobs</span>
</div>
<div style="padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;">
<p style="color: var(--warning); font-size: 13px;">
<%= pluralize(@stats[:blocked], "blocked job") %>.
</p>
<%= 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" %>
</div>
</div>
<% end %>
Expand Down
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
29 changes: 29 additions & 0 deletions spec/dummy/db/seeds.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down Expand Up @@ -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"
68 changes: 68 additions & 0 deletions spec/requests/solid_queue_web/dashboard_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }

Expand Down
Loading